Merge branch 'stable-2.13'

* stable-2.13:
  SetMembersCommand: Handle REST errors gracefully

Change-Id: I59cdf98ef4d24fa3f97fb4faaf7964b03b99b454
diff --git a/.bazelproject b/.bazelproject
new file mode 100644
index 0000000..b32683a
--- /dev/null
+++ b/.bazelproject
@@ -0,0 +1,26 @@
+# The project view file (.bazelproject) is used to import Gerrit Bazel packages into the IDE.
+#
+# See: https://ij.bazel.io/docs/project-views.html
+
+directories:
+  .
+  -eclipse-out
+  -contrib
+  -gerrit-package-plugins
+  -gerrit-plugin-gwt-archetype
+  -gerrit-plugin-js-archetype
+  -gerrit-plugin-archetype
+  -logs
+  -./.metadata
+  -./.settings
+  -./.apt_generated
+  # BUCK excludes; Remove after we have entirely switched to Bazel
+  -./.buckd
+  -bucklets
+
+targets:
+  //...:all
+
+java_language_level: 8
+
+workspace_type: java
diff --git a/.bazelrc b/.bazelrc
index 00acd27..a991c76 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1 +1 @@
-build --strategy=Javac=worker
+build --workspace_status_command=./tools/workspace-status.sh --strategy=Javac=worker
diff --git a/.buckconfig b/.buckconfig
index b347a96..60fd02a 100644
--- a/.buckconfig
+++ b/.buckconfig
@@ -20,8 +20,11 @@
 [java]
   jar_spool_mode = direct_to_jar
   src_roots = java, resources, src
+  source_level = 8
+  target_level = 8
 
 [project]
+  allow_symlinks = allow
   ignore = .git, eclipse-out, bazel-gerrit, bin
   parallel_parsing = true
 
diff --git a/.buckversion b/.buckversion
index f5fe016..af38772 100644
--- a/.buckversion
+++ b/.buckversion
@@ -1 +1 @@
-e64a2e2ada022f81e42be750b774024469551398
+7b7817c48f30687781040b2b82ac9218d5c4eaa4
diff --git a/.gitignore b/.gitignore
index 815c5fa..c89cfb8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
 /.settings/org.maven.ide.eclipse.prefs
 /.settings/org.eclipse.m2e.core.prefs
 /.settings/org.eclipse.ltk.core.refactoring.prefs
+/.metadata
 /test_site
 /.idea
 *.iml
diff --git a/.mailmap b/.mailmap
index 598d52d..fb8baff3 100644
--- a/.mailmap
+++ b/.mailmap
@@ -3,6 +3,7 @@
 Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@gs.com>
 Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex <alex.ryazantsev@gmail.com>
 Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex.ryazantsev <alex.ryazantsev@gmail.com>
+Becky Siegel <beckysiegel@google.com>                                                       beckysiegel <beckysiegel@google.com>
 Brad Larson <bklarson@gmail.com>                                                            <brad.larson@garmin.com>
 Bruce Zu <bruce.zu@sonymobile.com>                                                          <bruce.zu@sonyericsson.com>
 Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
@@ -11,6 +12,7 @@
 Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Turkoglu <deniz@spotify.com>
 Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@gmail.com>
 Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@sap.com>
+Edwin Kempin <ekempin@google.com>                                                           ekempin <ekempin@google.com>
 Eryk Szymanski <eryksz@gmail.com>                                                           <eryksz@google.com>
 Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik@gandaraj.com>
 Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik.luthander@sonyericsson.com>
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index 828234b..fd57ff7 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -7,9 +7,9 @@
 org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
 org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
 org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
-org.eclipse.jdt.core.compiler.compliance=1.7
+org.eclipse.jdt.core.compiler.compliance=1.8
 org.eclipse.jdt.core.compiler.debug.lineNumber=generate
 org.eclipse.jdt.core.compiler.debug.localVariable=generate
 org.eclipse.jdt.core.compiler.debug.sourceFile=generate
@@ -113,7 +113,7 @@
 org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
 org.eclipse.jdt.core.compiler.processAnnotations=enabled
-org.eclipse.jdt.core.compiler.source=1.7
+org.eclipse.jdt.core.compiler.source=1.8
 org.eclipse.jdt.core.formatter.align_type_members_on_columns=false
 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=16
 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation=0
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..a6d5067
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,25 @@
+load('//tools/bzl:pkg_war.bzl', 'pkg_war')
+
+genrule(
+  name = 'gen_version',
+  stamp = 1,
+  cmd = ("cat bazel-out/volatile-status.txt bazel-out/stable-status.txt | " +
+    "grep STABLE_BUILD_GERRIT_LABEL | cut -d ' ' -f 2 > $@"),
+  outs = ['version.txt'],
+  visibility = ['//visibility:public'],
+)
+
+genrule(
+  name = "LICENSES",
+  srcs = ["//Documentation:licenses.txt"],
+  cmd = "cp $< $@",
+  outs = ["LICENSES.txt"],
+  visibility = ['//visibility:public'],
+)
+
+pkg_war(name = 'gerrit')
+pkg_war(name = 'headless', ui = None)
+pkg_war(name = "polygerrit", ui = "polygerrit")
+pkg_war(name = 'release', ui = 'ui_optdbg_r', context = ['//plugins:core'], doc = True)
+pkg_war(name = 'withdocs', doc = True)
+
diff --git a/Documentation/BUILD b/Documentation/BUILD
new file mode 100644
index 0000000..081aa83
--- /dev/null
+++ b/Documentation/BUILD
@@ -0,0 +1,100 @@
+package(default_visibility = ['//visibility:public'])
+
+load("//tools/bzl:asciidoc.bzl", "documentation_attributes")
+load("//tools/bzl:asciidoc.bzl", "genasciidoc")
+load("//tools/bzl:asciidoc.bzl", "genasciidoc_zip")
+load("//tools/bzl:license.bzl", "license_map")
+
+exports_files([
+  "replace_macros.py",
+])
+
+filegroup(
+  name = "prettify_files",
+  srcs = [
+    ":prettify.min.css",
+    ":prettify.min.js",
+  ],
+)
+
+genrule(
+  name = "prettify_min_css",
+  srcs = ["//gerrit-prettify:src/main/resources/com/google/gerrit/prettify/client/prettify.css"],
+  cmd = "cp $< $@",
+  outs = ["prettify.min.css"],
+)
+
+genrule(
+  name = "prettify_min_js",
+  srcs = ["//gerrit-prettify:src/main/resources/com/google/gerrit/prettify/client/prettify.js"],
+  cmd = "cp $< $@",
+  outs = ["prettify.min.js"],
+)
+
+filegroup(
+  name = "resources",
+  srcs = glob([
+    "images/*.jpg",
+    "images/*.png",
+  ]) + [
+    ":prettify_files",
+    "//:LICENSES.txt",
+  ],
+  visibility = ['//visibility:public'],
+)
+
+license_map(
+  name = "licenses",
+  targets = [
+    "//gerrit-pgm:pgm",
+    "//gerrit-gwtui:ui_module",
+  ],
+  opts = ["--asciidoctor"],
+  visibility = ['//visibility:public'],
+)
+
+DOC_DIR = "Documentation"
+SRCS = glob(["*.txt"]) + [":licenses.txt"]
+
+genrule(
+  name = "index",
+  cmd = "$(location //lib/asciidoctor:doc_indexer) " +
+      "-o $(OUTS) " +
+      '--prefix "%s/" ' % DOC_DIR +
+      '--in-ext ".txt" ' +
+      '--out-ext ".html" ' +
+      "$(SRCS)",
+  tools = ["//lib/asciidoctor:doc_indexer"],
+  srcs = SRCS,
+  outs = ["index.jar"],
+)
+
+# For the same srcs, we can have multiple genasciidoc_zip rules, but only one
+# genasciidoc rule. Because multiple genasciidoc rules will have conflicting
+# output files.
+genasciidoc(
+  name = "Documentation",
+  srcs = SRCS,
+  attributes = documentation_attributes(),
+  backend = "html5",
+  visibility = ["//visibility:public"],
+)
+
+genasciidoc_zip(
+  name = "html",
+  srcs = SRCS,
+  attributes = documentation_attributes(),
+  backend = "html5",
+  directory = DOC_DIR,
+  visibility = ["//visibility:public"],
+)
+
+genasciidoc_zip(
+  name = "searchfree",
+  srcs = SRCS,
+  attributes = documentation_attributes(),
+  backend = "html5",
+  directory = DOC_DIR,
+  searchbox = False,
+  visibility = ["//visibility:public"],
+)
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 2cc8c05..41c9dc2 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -466,7 +466,10 @@
 
 To push lightweight (non-annotated) tags, grant
 `Create Reference` for reference name `+refs/tags/*+`, as lightweight
-tags are implemented just like branches in Git.
+tags are implemented just like branches in Git. To push a lightweight
+tag on a new commit (commit not reachable from any branch/tag) grant
+`Push` permission on `+refs/tags/*+` too. The `Push` permission on
+`+refs/tags/*+` also allows fast-forwarding of lightweight tags.
 
 For example, to grant the possibility to create new branches under the
 namespace `foo`, you have to grant this permission on
@@ -480,6 +483,19 @@
 you grant the users the push force permission to be able to clean up
 stale branches.
 
+[[category_delete]]
+=== Delete Reference
+
+The delete reference category controls whether it is possible to delete
+references, branches or tags. It doesn't allow any other update of
+references.
+
+Deletion of references is also possible if `Push` with the force option
+is granted, however that includes the permission to fast-forward and
+force-update references to exiting and new commits. Being able to push
+references for new commits is bad if bypassing of code review must be
+prevented.
+
 
 [[category_forge_author]]
 === Forge Author
@@ -599,11 +615,10 @@
 a new commit on their local system, so in practice they must also
 have the `Read` access granted to upload a change.
 
-For an open source, public Gerrit installation, it is common to
-grant `Read` and `Push` for `+refs/for/refs/heads/*+`
-to `Registered Users` in the `All-Projects` ACL.  For more
-private installations, its common to simply grant `Read` and
-`Push` for `+refs/for/refs/heads/*+` to all users of a project.
+For an open source, public Gerrit installation, it is common to grant
+`Push` for `+refs/for/refs/heads/*+` to `Registered Users` in the
+`All-Projects` ACL.  For more private installations, its common to
+grant `Push` for `+refs/for/refs/heads/*+` to all users of a project.
 
 * Force option
 +
@@ -644,7 +659,8 @@
 
 
 [[category_push_annotated]]
-=== Push Annotated Tag
+[[category_create_annotated]]
+=== Create Annotated Tag
 
 This category permits users to push an annotated tag object into the
 project's repository.  Typically this would be done with a command line
@@ -671,7 +687,7 @@
 
 To push tags created by users other than the current user (such
 as tags mirrored from an upstream project), `Forge Committer Identity`
-must be also granted in addition to `Push Annotated Tag`.
+must be also granted in addition to `Create Annotated Tag`.
 
 To push lightweight (non annotated) tags, grant
 <<category_create,`Create Reference`>> for reference name
@@ -682,9 +698,16 @@
 option enabled for reference name `+refs/tags/*+`, as deleting a tag
 requires the same permission as deleting a branch.
 
+To push an annotated tag on a new commit (commit not reachable from any
+branch/tag) grant `Push` permission on `+refs/tags/*+` too.
+The `Push` permission on `+refs/tags/*+` does *not* allow updating of annotated
+tags, not even fast-forwarding of annotated tags. Update of annotated tags
+is only allowed by granting `Push` with `force` option on `+refs/tags/*+`.
+
 
 [[category_push_signed]]
-=== Push Signed Tag
+[[category_create_signed]]
+=== Create Signed Tag
 
 This category permits users to push a PGP signed tag object into the
 project's repository.  Typically this would be done with a command
@@ -863,6 +886,14 @@
 can always edit or remove hashtags (even without having the `Edit Hashtags`
 access right assigned).
 
+[[category_edit_assigned_to]]
+=== Edit Assignee
+
+This category permits users to set who is assigned to a change that is
+uploaded for review.
+
+The change owner, ref owners, and the user currently assigned to a change
+can always change the assignee.
 
 [[example_roles]]
 == Examples of typical roles in a project
@@ -997,7 +1028,7 @@
 * <<category_push_merge,`Push merge commit`>> to 'refs/heads/*'
 * <<category_forge_committer,`Forge Committer Identity`>> to 'refs/for/refs/heads/*'
 * <<category_create,`Create Reference`>> to 'refs/heads/*'
-* <<category_push_annotated,`Push Annotated Tag`>> to 'refs/tags/*'
+* <<category_create_annotated,`Create Annotated Tag`>> to 'refs/tags/*'
 
 
 [[examples_project-owner]]
@@ -1067,12 +1098,15 @@
 [[block]]
 === 'BLOCK' access rule
 
-The 'BLOCK' rule blocks a permission globally. An inherited 'BLOCK' rule cannot
-be overridden in the inheriting project. Any 'ALLOW' rule, from a different
-access section or from an inheriting project, which conflicts with an
-inherited 'BLOCK' rule will not be honored.  Searching for 'BLOCK' rules, in
-the chain of parent projects, ignores the Exclusive flag that is normally
-applied to access sections.
+The 'BLOCK' rule blocks a permission globally. An inherited 'BLOCK'
+rule cannot be overridden in the inheriting project. Any 'ALLOW' rule
+from an inheriting project, which conflicts with an inherited 'BLOCK'
+rule will not be honored. Searching for 'BLOCK' rules, in the chain
+of parent projects, ignores the Exclusive flag, unless the rule with
+the Exclusive flag is defined on the same project as the 'BLOCK'
+rule. This means within the same project a 'BLOCK' rule can be
+overruled by 'ALLOW' rules on the same access section and 'ALLOW'
+rules with Exclusive flag on access section for more specific refs.
 
 A 'BLOCK' rule that blocks the 'push' permission blocks any type of push,
 force or not. A blocking force push rule blocks only force pushes, but
diff --git a/Documentation/cmd-index-start.txt b/Documentation/cmd-index-start.txt
index fbe4f3f..769360d 100644
--- a/Documentation/cmd-index-start.txt
+++ b/Documentation/cmd-index-start.txt
@@ -20,6 +20,8 @@
 Gerrit. This command will not start the indexer if it is already running or if
 the active index is the latest.
 
+The link:cmd-show-queue.html[show-queue] command provides online index status.
+
 == ACCESS
 Caller must be a member of the privileged 'Administrators' group.
 
diff --git a/Documentation/cmd-show-queue.txt b/Documentation/cmd-show-queue.txt
index 02f1c5b..141f7e2 100644
--- a/Documentation/cmd-show-queue.txt
+++ b/Documentation/cmd-show-queue.txt
@@ -1,7 +1,7 @@
 = gerrit show-queue
 
 == NAME
-gerrit show-queue - Display the background work queues, including replication
+gerrit show-queue - Display the background work queues, including replication and indexing
 
 == SYNOPSIS
 [verse]
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index 1cfb8b9..8ce7d7e 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -1,5 +1,4 @@
 = gerrit stream-events
-
 == NAME
 gerrit stream-events - Monitor events occurring in real time
 
@@ -59,6 +58,21 @@
 
 [[events]]
 == EVENTS
+=== Assignee Changed
+
+Sent when the assignee of a change has been modified.
+
+type:: "assignee-changed"
+
+change:: link:json.html#change[change attribute]
+
+changer:: link:json.html#account[account attribute]
+
+oldAssignee:: Assignee before it was changed.
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 === Change Abandoned
 
 Sent when a change has been abandoned.
diff --git a/Documentation/config-cla.txt b/Documentation/config-cla.txt
index c07a24f..2234808 100644
--- a/Documentation/config-cla.txt
+++ b/Documentation/config-cla.txt
@@ -37,8 +37,13 @@
 Each `contributor-agreement` section within the `project.config` file must
 have a unique name. The section name will appear in the web UI.
 
-If not already present, add the UUID of the groups used in the
-`autoVerify` and `accepted` variables in the groups file.
+If not already present, add the group(s) used in the `autoVerify` and
+`accepted` variables in the `groups` file:
+----
+    # UUID                                  	Group Name
+    #
+    3dedb32915ecdbef5fced9f0a2587d164cd614d4	CLA Accepted - Individual
+----
 
 Commit the configuration change, and push it back:
 ----
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index a956d52..462c226 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -67,6 +67,15 @@
 +
 Default is 20.
 
+[[addReviewer.baseWeight]]addReviewer.baseWeight::
++
+The weight that will be applied in the default reviewer ranking algorithm.
+This can be increased or decreased to give more or less influence to plugins.
+If set to zero, the base ranking will not have any effect. Reviewers will then
+be ordered as ranked by the plugins (if there are any).
++
+By default 1.
+
 [[auth]]
 === Section auth
 
@@ -443,7 +452,7 @@
 [[auth.gitBasicAuth]]auth.gitBasicAuth::
 +
 If true then Git over HTTP and HTTP/S traffic is authenticated using
-standard BasicAuth. Depending on the configured `auth.type` credentials
+standard BasicAuth. Depending on the configured `auth.type`, credentials
 are validated against the randomly generated HTTP password, against LDAP
 (`auth.type = LDAP`) or against an OAuth 2 provider (`auth.type = OAUTH`).
 +
@@ -452,8 +461,14 @@
 authentication and the randomly generated HTTP password in the Gerrit
 database.
 +
-When `auth.type` is `LDAP`, service users that only exist in the Gerrit
-database are still authenticated by their HTTP passwords.
+When `auth.type` is `LDAP`, users should authenticate using their LDAP passwords.
+However, if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP`,
+the randomly generated HTTP password is used exclusively. In the other hand,
+if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
+the password in the request is first checked against the HTTP password and, if
+it does not match, it is then validated against the LDAP password.
+Service users that only exist in the Gerrit database are authenticated by their
+HTTP passwords.
 +
 When `auth.type` is `OAUTH`, Git clients may send OAuth 2 access tokens
 instead of passwords in the Basic authentication header. Note that provider
@@ -463,6 +478,31 @@
 +
 By default this is set to false.
 
+[[auth.gitBasicAuthPolicy]]auth.gitBasicAuthPolicy::
++
+When `auth.type` is `LDAP` and BasicAuth (i.e., link:#auth.gitBasicAuth[`auth.gitBasicAuth`]
+is set to true), it allows using either the generated HTTP password, the LDAP
+password or both to authenticate Git over HTTP and REST API requests. The
+supported values are:
++
+*`HTTP`
++
+Only the randomly generated HTTP password is accepted when doing Git over HTTP
+and REST API requests.
++
+*`LDAP`
++
+Only the `LDAP` password is allowed when doing Git over HTTP and REST API
+requests.
++
+*`HTTP_LDAP`
++
+The password in the request is first checked against the HTTP password and, if
+it does not match, it is then validated against the `LDAP` password.
++
+By default this is set to `LDAP` when link:#auth.type[`auth.type`] is `LDAP`.
+Otherwise, the default value is `HTTP`.
+
 [[auth.gitOAuthProvider]]auth.gitOAuthProvider::
 +
 Selects the OAuth 2 provider to authenticate git over HTTP traffic with.
@@ -611,6 +651,7 @@
 * `"adv_bases"`: default is `4096`
 * `"diff"`: default is `10m` (10 MiB of memory)
 * `"diff_intraline"`: default is `10m` (10 MiB of memory)
+* `"diff_summary"`: default is `10m` (10 MiB of memory)
 * `"plugin_resources"`: default is 2m (2 MiB of memory)
 
 +
@@ -626,7 +667,10 @@
 grow larger than this during the day, as the size check is only
 performed once every 24 hours.
 +
-Default is 128 MiB per cache.
+Default is 128 MiB per cache, except:
++
+* `"diff_summary"`: default is `1g` (1 GiB of disk space)
+
 +
 If 0, disk storage for the cache is disabled.
 
@@ -698,6 +742,16 @@
 cache.diff.memoryLimit to fit all files users will view in a 1 or 2
 day span.
 
+cache `"diff_summary"`::
++
+Each item caches list of file paths which are different between two
+commits. Gerrit uses this cache to accelerate computing of the list
+of paths of changed files.
++
+Ideally, disk limit of this cache is large enough to cover all changes.
+This should significantly speed up change reindexing, especially
+full offline reindexing.
+
 cache `"git_tags"`::
 +
 If branch or reference level READ access controls are used, this
@@ -980,6 +1034,13 @@
 +
 Default is true.
 
+[[change.showAssignee]]change.showAssignee::
++
+Allow assignee workflow. If set to false, assignees will not be visible anywhere
+in UI.
++
+Default is true.
+
 [[change.submitLabel]]change.submitLabel::
 +
 Label name for the submit button.
@@ -2409,6 +2470,10 @@
 +
 A link:http://lucene.apache.org/[Lucene] index is used.
 +
++
+* `ELASTICSEARCH`
++
+An link:http://www.elasticsearch.org/[Elasticsearch] index is used.
 
 +
 By default, `LUCENE`.
@@ -2533,6 +2598,43 @@
   maxBufferedDocs = 500
 ----
 
+
+==== Elasticsearch configuration
+
+WARNING: ElasticSearch implementation is incomplete. Right now it is
+still using parts of Lucene index.
+
+Open and closed changes are indexed in a single index, separated
+into types 'open_changes' and 'closed_changes' respectively.
+
+The following settings are only used when the index type is
+`ELASTICSEARCH`.
+
+[[index.protocol]]index.protocol::
++
+Elasticsearch server protocol [http|https].
++
+Defaults to `http`.
+
+[[index.hostname]]index.hostname::
++
+Elasticsearch server hostname.
+
+Defaults to `localhost`.
+
+[[index.port]]index.port::
++
+Elasticsearch server port.
++
+Defauls to `9200`.
+
+[[index.name]]index.name::
++
+This setting can be used to index changes from multiple Gerrit
+instances in a single Elasticsearch cluster.
++
+Defaults to 'gerrit'.
+
 [[ldap]]
 === Section ldap
 
@@ -3327,6 +3429,14 @@
 +
 By default, true, allowing notifications to be sent.
 
+[[sendemail.html]]sendemail.html::
++
+If false, Gerrit will only send plain-text emails.
+If true, Gerrit will send multi-part emails with an HTML and
+plain text part.
++
+By default, true, allowing HTML in the emails Gerrit sends.
+
 [[sendemail.connectTimeout]]sendemail.connectTimeout::
 +
 The connection timeout of opening a socket connected to a
@@ -3358,7 +3468,9 @@
 Full Name and Preferred Email.  This may cause messages to be
 classified as spam if the user's domain has SPF or DKIM enabled
 and <<sendemail.smtpServer,sendemail.smtpServer>> is not a trusted
-relay for that domain.
+relay for that domain. You can specify
+<<sendemail.allowedDomain,sendemail.allowedDomain>> to instruct Gerrit to only
+send as USER if USER is from those domains.
 +
 * `MIXED`
 +
@@ -3384,6 +3496,16 @@
 +
 By default, MIXED.
 
+[[sendemail.allowedDomain]]sendemail.allowedDomain::
++
+Only used when `sendemail.from` is set to `USER`.
+List of allowed domains. If user's email matches one of the domains, emails will
+be sent as USER, otherwise as MIXED mode. Wildcards may be specified by
+including `*` to match any number of characters, for example `*.example.com`
+matches any subdomain of `example.com`.
++
+By default, `*`.
+
 [[sendemail.smtpServer]]sendemail.smtpServer::
 +
 Hostname (or IP address) of a SMTP server that will relay
@@ -3469,6 +3591,15 @@
 [[site]]
 === Section site
 
+[[site.allowOriginRegex]]site.allowOriginRegex::
++
+List of regular expressions matching origins that should be permitted
+to use the Gerrit REST API to read content. These should be trusted
+applications as the sites may be able to use the user's credentials.
+Only applies to GET and HEAD requests.
++
+By default, unset, denying all cross-origin requests.
+
 [[site.refreshHeaderFooter]]site.refreshHeaderFooter::
 +
 If true the server checks the site header, footer and CSS files for
@@ -3785,11 +3916,11 @@
 [[suggest.from]]suggest.from::
 +
 The number of characters that a user must have typed before suggestions
-are provided. If set to 0, suggestions are always provided.
+are provided. If set to 0, suggestions are always provided. This is only
+used for suggesting accounts when adding members to a group.
 +
 By default 0.
 
-
 [[theme]]
 === Section theme
 
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 51ea9c5..63a4344 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -1,163 +1,160 @@
 = Gerrit Code Review - Mail Templates
 
-Gerrit uses velocity templates for the bulk of the standard mails it sends out.
+Gerrit uses Closure Templates for the bulk of the standard mails it sends out.
 There are builtin default templates which are used if they are not overridden.
 These defaults are also provided as examples so that administrators may copy
 them and easily modify them to tweak their contents.
 
+*Compatibility Note:* previously, Velocity Template Language (VTL) was used as
+the template language for Gerrit emails. VTL has now been deprecated in favor of
+Soy, but Velocity templates that modify text emails remain supported for now.
 
 == Template Locations and Extensions:
 
 The default example templates reside under:  `'$site_path'/etc/mail` and are
-terminated with the double extension `.vm.example`. Modifying these example
+terminated with the double extension `.soy.example`. Modifying these example
 files will have no effect on the behavior of Gerrit.  However, copying an
 example template to an equivalently named file without the `.example` extension
 and modifying it will allow an administrator to customize the template.
 
-
 == Supported Mail Templates:
 
 Each mail that Gerrit sends out is controlled by at least one template.  These
 are listed below.  Change emails are influenced by two additional templates,
 one to set the subject line, and one to set the footer which gets appended to
-all the change emails (see `ChangeSubject.vm` and `ChangeFooter.vm` below.)
+all the change emails (see `ChangeSubject.soy` and `ChangeFooter.soy` below.)
 
-=== Abandoned.vm
+Many types of Gerrit email message support HTML in addition to plain-text. Where
+both are supported, templates to control the HTML part have `...Html` appended
+in their file names. For example, for "Abandoned" emails, the `Abandoned.soy`
+template determines the text part of the message, whereas `AbandonedHtml.soy`
+determines the HTML part.
 
-The `Abandoned.vm` template will determine the contents of the email related
-to a change being abandoned.  It is a `ChangeEmail`: see `ChangeSubject.vm` and
-`ChangeFooter.vm`.
+=== Abandoned.soy and AbandonedHtml.soy
 
-=== AddKey.vm
+The "Abandoned" templates will determine the contents of the email related to a
+change being abandoned.  It is a `ChangeEmail`: see `ChangeSubject.soy` and
+ChangeFooter.
 
-The `AddKey.vm` template will determine the contents of the email related to
-SSH and GPG keys being added to a user account. This notification is not sent
-when the key is administratively added to another user account.
+=== AddKey.soy and AddKeyHtml.soy
 
-=== ChangeFooter.vm
+AddKey templates will determine the contents of the email related to SSH and GPG
+keys being added to a user account. This notification is not sent when the key
+is administratively added to another user account.
 
-The `ChangeFooter.vm` template will determine the contents of the footer
-text that will be appended to emails related to changes (all `ChangeEmail`s).
+=== ChangeFooter.soy and ChangeFooterHtml.soy
 
-=== ChangeSubject.vm
+The ChangeFooter templates will determine the contents of the footer that will
+be appended to emails related to changes (all `ChangeEmail`s).
 
-The `ChangeSubject.vm` template will determine the contents of the email
+=== ChangeSubject.soy
+
+The `ChangeSubject.soy` template will determine the contents of the email
 subject line for ALL emails related to changes.
 
-=== Comment.vm
+=== Comment.soy
 
-The `Comment.vm` template will determine the contents of the email related to
+The `Comment.soy` template will determine the contents of the email related to
 a user submitting comments on changes.  It is a `ChangeEmail`: see
-`ChangeSubject.vm`, `ChangeFooter.vm` and `CommentFooter.vm`.
+`ChangeSubject.soy`, ChangeFooter and CommentFooter.
 
-=== CommentFooter.vm
+=== CommentFooter.soy and CommentFooterHtml.soy
 
-The `CommentFooter.vm` template will determine the contents of the footer
-text that will be appended to emails related to a user submitting comments on
-changes.  See `ChangeSubject.vm`, `Comment.vm` and `ChangeFooter.vm`.
+The CommentFooter templates will determine the contents of the footer text that
+will be appended to emails related to a user submitting comments on changes.
+See `ChangeSubject.soy`, Comment and ChangeFooter.
 
-=== DeleteVote.vm
+=== DeleteVote.soy and DeleteVoteHtml.soy
 
-The `DeleteVote.vm` template will determine the contents of the email related
-to removing votes on changes.  It is a `ChangeEmail`: see `ChangeSubject.vm`
-and `ChangeFooter.vm`.
+The DeleteVote templates will determine the contents of the email related to
+removing votes on changes.  It is a `ChangeEmail`: see `ChangeSubject.soy`
+and ChangeFooter.
 
-=== DeleteReviewer.vm
+=== DeleteReviewer.soy and DeleteReviewerHtml.soy
 
-The `DeleteReviewer.vm` template will determine the contents of the email related
-to a user removing a reviewer (with a vote) from a change.  It is a
-`ChangeEmail`: see `ChangeSubject.vm` and `ChangeFooter.vm`.
+The DeleteReviewer templates will determine the contents of the email related to
+a user removing a reviewer (with a vote) from a change.  It is a
+`ChangeEmail`: see `ChangeSubject.soy` and ChangeFooter.
 
-=== Footer.vm
+=== Footer.soy and FooterHtml.soy
 
-The `Footer.vm` template will determine the contents of the footer text
-appended to the end of all outgoing emails after the ChangeFooter and
-CommentFooter.
+The Footer templates will determine the contents of the footer text appended to
+the end of all outgoing emails after the ChangeFooter and CommentFooter.
 
-=== Merged.vm
+=== Merged.soy and MergedHtml.soy
 
-The `Merged.vm` template will determine the contents of the email related to
-a change successfully merged to the head.  It is a `ChangeEmail`: see
-`ChangeSubject.vm` and `ChangeFooter.vm`.
+The Merged templates will determine the contents of the email related to a
+change successfully merged to the head.  It is a `ChangeEmail`: see
+`ChangeSubject.soy` and ChangeFooter.
 
-=== NewChange.vm
+=== NewChange.soy and NewChangeHtml.soy
 
-The `NewChange.vm` template will determine the contents of the email related
-to a user submitting a new change for review. This includes changes created
-by actions made by the user in the Web UI such as cherry picking a commit or
-reverting a change.  It is a `ChangeEmail`: see `ChangeSubject.vm` and
-`ChangeFooter.vm`.
+The NewChange templates will determine the contents of the email related to a
+user submitting a new change for review. This includes changes created by
+actions made by the user in the Web UI such as cherry picking a commit or
+reverting a change.  It is a `ChangeEmail`: see `ChangeSubject.soy` and
+ChangeFooter.
 
-=== RegisterNewEmail.vm
+=== RegisterNewEmail.soy
 
-The `RegisterNewEmail.vm` template will determine the contents of the email
+The `RegisterNewEmail.soy` template will determine the contents of the email
 related to registering new email accounts.
 
-=== ReplacePatchSet.vm
+=== ReplacePatchSet.soy and ReplacePatchSetHtml.soy
 
-The `ReplacePatchSet.vm` template will determine the contents of the email
-related to a user submitting a new patchset for a change.  This includes
-patchsets created by actions made by the user in the Web UI such as editing
-the commit message, cherry picking a commit, or rebasing a change.  It is a
-`ChangeEmail`: see `ChangeSubject.vm` and `ChangeFooter.vm`.
+The ReplacePatchSet templates will determine the contents of the email related
+to a user submitting a new patchset for a change.  This includes patchsets
+created by actions made by the user in the Web UI such as editing the commit
+message, cherry picking a commit, or rebasing a change.  It is a `ChangeEmail`:
+see `ChangeSubject.soy` and ChangeFooter.
 
-=== Restored.vm
+=== Restored.soy and RestoredHtml.soy
 
-The `Restored.vm` template will determine the contents of the email related
-to a change being restored.  It is a `ChangeEmail`: see `ChangeSubject.vm` and
-`ChangeFooter.vm`.
+The Restored templates will determine the contents of the email related to a
+change being restored.  It is a `ChangeEmail`: see `ChangeSubject.soy` and
+ChangeFooter.
 
-=== Reverted.vm
+=== Reverted.soy and RevertedHtml.soy
 
-The `Reverted.vm` template will determine the contents of the email related
-to a change being reverted.  It is a `ChangeEmail`: see `ChangeSubject.vm` and
-`ChangeFooter.vm`.
+The Reverted templates will determine the contents of the email related to a
+change being reverted.  It is a `ChangeEmail`: see `ChangeSubject.soy` and
+ChangeFooter.
 
 
 == Mail Variables and Methods
 
 Mail templates can access and display objects currently made available to them
-via the velocity context.  While the base objects are documented here, it is
-possible to call public methods on these objects from templates.  Those methods
-are not documented here since they could change with every release.  As these
-templates are meant to be modified only by a qualified sysadmin, it is accepted
-that writing templates for Gerrit emails is likely to require some basic
-knowledge of the class structure to be useful.  Browsing the source code might
-be necessary for anything more than a minor formatting change.
+via the Soy context.
 
 === Warning
 
 Be aware that modifying templates can cause them to fail to parse and therefore
-not send out the actual email, or worse, calling methods on the available
-objects could have internal side effects which would adversely affect the
-health of your Gerrit server and/or data.
+not send out the actual email.
 
 === All OutgoingEmails
 
 All outgoing emails have the following variables available to them:
 
-$email::
+$email.settingsUrl::
 +
-A reference to the class constructing the current `OutgoingEmail`.  With this
-reference it is possible to call any public method on the OutgoingEmail class
-or the current child class inherited from it.
+The URL to view the user's settings in the Gerrit web UI.
+
+$email.gerritHost::
++
+The name of the Gerrit instance.
+
+$email.gerritUrl::
++
+The URL to the Gerrit web UI.
 
 $messageClass::
 +
 A String containing the messageClass.
 
-$StringUtils::
-+
-A reference to the Apache `StringUtils` class.  This can be very useful for
-formatting strings.
-
 === Change Emails
 
-All change related emails have the following additional variables available to them:
-
-$change::
-+
-A reference to the current `Change` object.
+Change related emails have the following template data available to them, in
+addition to what's available to all outgoing emails.
 
 $changeId::
 +
@@ -167,30 +164,69 @@
 +
 The text of the `ChangeMessage`.
 
-$branch::
-+
-A reference to the branch of this change (a `Branch.NameKey`).
-
 $fromName::
 +
 The name of the from user.
 
+$email.unifiedDiff::
++
+The diff of the change.
+
+$email.changeDetail::
++
+The details of the change, including the commit message.
+
+$email.changeUrl::
++
+The URL to the change in the web UI.
+
+$email.includeDiff::
++
+Whether the Gerrit instance is configured to include diffs in emails.
+
+$change.subject::
++
+The subject of the current change.
+
+$change.originalSubject::
++
+The subject corresponding to the first patch set of the current change.
+
+$change.shortSubject::
++
+The subject limited to 63 characters, with an ellipsis if it exceeds that.
+
+$change.ownerEmail::
++
+The email address of the owner of the change.
+
+$branch.shortName::
++
+The name of the branch targeted by the current change.
+
 $projectName::
 +
 The name of this change's project.
 
-$patchSet::
+$shortProjectName::
 +
-A reference to the current `PatchSet`.
+The project name with the path abbreviated.
 
-$patchSetInfo::
+$sshHost::
 +
-A reference to the current `PatchSetInfo`.
+SSH hostname for the Gerrit instance.
 
+$patchSet.patchSetId::
++
+The current patch set number.
+
+$patchSet.refname::
++
+The refname of the patch set.
 
 == SEE ALSO
 
-* link:http://velocity.apache.org/[velocity]
+* link:https://developers.google.com/closure/templates/[Closure Templates]
 
 GERRIT
 ------
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 7121265..34f39c8 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -20,6 +20,11 @@
 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.
 
+== Property inheritance
+
+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.
 
 [[file-project_config]]
 == The file +project.config+
@@ -79,6 +84,11 @@
 also redefine the text and behavior of the built in label types `Code-Review`
 and `Verified`.
 
+Optionally a +commentlink+ section can be added to define project-specific
+comment links. The +commentlink+ section has the same format as the
+link:config-gerrit.html#commentlink[+commentlink+ section in gerrit.config]
+which is used to define global comment links.
+
 [[project-section]]
 === Project section
 
diff --git a/Documentation/config-robot-comments.txt b/Documentation/config-robot-comments.txt
new file mode 100644
index 0000000..cf5de10
--- /dev/null
+++ b/Documentation/config-robot-comments.txt
@@ -0,0 +1,49 @@
+= Gerrit Code Review - Robot Comments
+
+Gerrit has special support for inline comments that are generated by
+automated third-party systems, so called "robot comments". For example
+robot comments can be used to represent the results of code analyzers.
+
+In contrast to regular inline comments which are free-text comments,
+robot comments are more structured and can contain additional data,
+such as a robot ID, a robot run ID and a URL, see
+link:rest-api-changes.html#robot-comment-info[RobotCommentInfo] for
+details.
+
+It is planned to visualize robot comments differently in the web UI so
+that they can be easily distinguished from human comments. Users should
+also be able to use filtering on robot comments, so that only part of
+the robot comments or no robot comments are shown. In addition it is
+planned that robot comments can contain fixes, that users can apply by
+a single click.
+
+== REST endpoints
+
+* Posting robot comments is done by the
+  link:rest-api-changes.html[Set Review] REST endpoint. The
+  link:rest-api-changes.html#review-input[input] for this REST endpoint
+  can contain robot comments in its `robot_comments` field.
+* link:rest-api-changes.html#list-robot-comments[List Robot Comments]
+* link:rest-api-changes.html#get-robot-comment[Get Robot Comment]
+
+== Storage
+
+Robot comments are stored per change in a
+`refs/changes/XX/YYYY/robot-comments` ref, where `XX/YYYY` is the
+sharded change ID.
+
+Robot comments can be dropped by deleting this ref.
+
+== Limitations
+
+* Robot comments are only supported with NoteDb, but not with ReviewDb.
+* Robot comments are not displayed in the web UI yet.
+* There is no support for draft robot comments, but robot comments are
+  always published and visible to everyone who can see the change.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index 2707e5c..cccfe5c 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -80,6 +80,13 @@
 E.g. a plugin could use this to enforce a certain name scheme for
 group names.
 
+[[assignee-validation]]
+== Assignee validation
+
+
+Plugins implementing the `AssigneeValidationListener` interface can perform
+validation of assignees before they are assigned to a change.
+
 [[hashtag-validation]]
 == Hashtag validation
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
new file mode 100644
index 0000000..97124cb
--- /dev/null
+++ b/Documentation/dev-bazel.txt
@@ -0,0 +1,301 @@
+= Gerrit Code Review - Building with Bazel
+
+Bazel build is experimental. Major missing parts:
+
+* PolyGerrit
+* License tracking
+* Version stamping
+* Custom plugins
+* Eclipse project generation.
+* Test suites for SSH, acceptance, etc.
+* tag tests as slow, flaky, etc.
+
+Nice to have:
+
+* JGit build from local tree.
+* Global maven artifact caching.
+* local.properties proxy config.
+* coverage
+
+== Installation
+
+You need to use Java 8 and Node.js for building gerrit.
+
+You can install Bazel from the bazel.io:
+https://www.bazel.io/versions/master/docs/install.html
+
+
+[[build]]
+== Building on the Command Line
+
+=== Gerrit Development WAR File
+
+To build the Gerrit web application that includes GWT UI and PolyGerrit UI:
+
+----
+  bazel build gerrit
+----
+
+The output executable WAR will be placed in:
+
+----
+  bazel-bin/gerrit.war
+----
+
+to run,
+
+----
+  $(bazel info output_base)/external/local_jdk/bin/java \
+     -jar bazel-bin/release.war daemon -d ../gerrit_testsite
+----
+
+=== Headless Mode
+
+To build Gerrit in headless mode, i.e. without the GWT Web UI:
+
+----
+  bazel build headless
+----
+
+The output executable WAR will be placed in:
+
+----
+  bazel-bin/headless/headless.war
+----
+
+=== Extension and Plugin API JAR Files
+
+To build the extension, plugin and GWT API JAR files:
+
+----
+  bazel build gerrit-plugin-api:plugin-api_deploy.jar
+  bazel build gerrit-extension-api:extension-api_deploy.jar
+----
+
+Java binaries, Java sources and Java docs are generated into corresponding
+project directories, here as example for plugin API:
+
+----
+  bazel-bin/gerrit-plugin-api/plugin-api_deploy.jar
+  bazel-bin/gerrit-extension-api/extension-api_deploy.jar
+----
+
+Install {extension,plugin,gwt}-api to the local maven repository:
+
+----
+  tools/maven/api.sh install bazel
+----
+
+Install gerrit.war to the local maven repository:
+
+----
+  tools/maven/api.sh war_install bazel
+----
+
+=== Plugins
+
+----
+  bazel build plugins:core
+----
+
+The output JAR files for individual plugins will be placed in:
+
+----
+  bazel-bin/plugins/<name>/<name>_deploy.jar
+----
+
+The JAR files will also be packaged in:
+
+----
+  bazel-genfiles/plugins/core.zip
+----
+
+To build a specific plugin:
+
+----
+  bazel build plugins/<name>:<name>_deploy.jar
+----
+
+The output JAR file will be be placed in:
+
+----
+  bazel-bin/plugins/<name>/<name>_deploy.jar
+----
+
+Note that when building an individual plugin, the `core.zip` package
+is not regenerated.
+
+
+
+[[IDEs]]
+== Using an IDE.
+
+=== IntelliJ
+
+The Gerrit build works with Bazel's [IntelliJ
+plugin](https://ij.bazel.io). Do the following:
+
+  * Install the plugin (requires IJ 2016.2 or newer)
+  * Select "File > Import Bazel project".
+  * Select "Workspace": (directory holding gerrit source)
+  * Select "project view: generate from BUILD": (enter top level BUILD file)
+
+[[documentation]]
+=== Documentation
+
+To build only the documentation for testing or static hosting:
+
+----
+  bazel build Documentation:searchfree
+----
+
+The html files will be bundled into `searchfree.zip` in this location:
+
+----
+  bazel-bin/Documentation/searchfree.zip
+----
+
+To build the executable WAR with the documentation included:
+
+----
+  bazel build withdocs
+----
+
+The WAR file will be placed in:
+
+----
+  bazel-bin/withdocs.war
+----
+
+[[release]]
+=== Gerrit Release WAR File
+
+----
+  bazel build release
+----
+
+[[tests]]
+== Running Unit Tests
+
+----
+  bazel test --build_tests_only //...
+----
+
+Debugging tests:
+
+----
+  bazel test --test_output=streamed --test_filter=com.gerrit.TestClass.testMethod  //...
+----
+
+To run a specific test group, e.g. the rest-account test group:
+
+----
+  bazel test //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:rest-account
+----
+
+== Dependencies
+
+Dependency JARs are normally downloaded automatically, but Buck can inspect
+its graph and download any missing JAR files.  This is useful to enable
+subsequent builds to run without network access:
+
+----
+  tools/download_all.py
+----
+
+When downloading from behind a proxy (which is common in some corporate
+environments), it might be necessary to explicitly specify the proxy that
+is then used by `curl`:
+
+----
+  export http_proxy=http://<proxy_user_id>:<proxy_password>@<proxy_server>:<proxy_port>
+----
+
+
+== Building against unpublished Maven JARs
+
+To build against unpublished Maven JARs, like gwtorm or PrologCafe, the custom
+JARs must be installed in the local Maven repository (`mvn clean install`) and
+`maven_jar()` must be updated to point to the `MAVEN_LOCAL` Maven repository for
+that artifact:
+
+[source,python]
+----
+ maven_jar(
+   name = 'gwtorm',
+   id = 'gwtorm:gwtorm:42',
+   license = 'Apache2.0',
+   repository = MAVEN_LOCAL,
+ )
+----
+
+== Building against artifacts from custom Maven repositories
+
+To build against custom Maven repositories, two modes of operations are
+supported: with rewrite in local.properties and without.
+
+Without rewrite the URL of custom Maven repository can be directly passed
+to the maven_jar() function:
+
+[source,python]
+----
+  GERRIT_FORGE = 'http://gerritforge.com/snapshot'
+
+  maven_jar(
+    name = 'gitblit',
+    id = 'com.gitblit:gitblit:1.4.0',
+    sha1 = '1b130dbf5578ace37507430a4a523f6594bf34fa',
+    license = 'Apache2.0',
+    repository = GERRIT_FORGE,
+ )
+----
+
+When the custom URL has to be rewritten, then the same logic as with Gerrit
+known Maven repository is used: Repo name must be defined that matches an entry
+in local.properties file:
+
+----
+  download.GERRIT_FORGE = http://my.company.mirror/gerrit-forge
+----
+
+And corresponding WORKSPACE excerpt:
+
+[source,python]
+----
+  GERRIT_FORGE = 'GERRIT_FORGE:'
+
+  maven_jar(
+    name = 'gitblit',
+    id = 'com.gitblit:gitblit:1.4.0',
+    sha1 = '1b130dbf5578ace37507430a4a523f6594bf34fa',
+    license = 'Apache2.0',
+    repository = GERRIT_FORGE,
+ )
+----
+
+
+[[clean-cache]]
+=== Cleaning The download cache
+
+The cache for the Gerrit Code Review project is located in
+`~/.gerritcodereview/buck-cache/locally-built-artifacts`.
+
+If you really do need to clean the cache manually, then:
+
+----
+ rm -rf ~/.gerritcodereview/buck-cache/locally-built-artifacts
+----
+
+Note that the root `buck-cache` folder should not be deleted as it also contains
+the `downloaded-artifacts` directory, which holds the artifacts that got
+downloaded (not built locally).
+
+
+== Known issues and bugs
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-buck.txt b/Documentation/dev-buck.txt
index 315c0b0..9deed50 100644
--- a/Documentation/dev-buck.txt
+++ b/Documentation/dev-buck.txt
@@ -3,7 +3,7 @@
 
 == Installation
 
-You need to use Java 7 and Node.js for building gerrit.
+You need to use Java 8 and Node.js for building gerrit.
 
 There is currently no binary distribution of Buck, so it has to be manually
 built and installed.  Apache Ant and gcc are required.  Currently only Linux
@@ -547,7 +547,7 @@
 
 ----
   cat > .buckjavaargs <<EOF
-  -XX:MaxPermSize=512m -Xms8000m -Xmx16000m
+  -Xms8000m -Xmx16000m
   EOF
 ----
 
@@ -609,6 +609,42 @@
 buck test --no-results-cache
 ----
 
+== Cross-compiling Java8 to Java7
+
+After switching to Java8, we should take care to not end up
+with Java8 code in stable branches. We assume that we don't
+really want to switch java versions locally every time we switch
+branches.
+
+Given that source level on 'stable-2.13' is 7, source level incompatibility
+will be already correctly detected, so that Java8 compiler would refuse
+to compile lambdas with -source 7 argument. However, unless bootclasspath
+is adjusted to point to Java7 runtime, it's possible to end up with broken
+code, that would compile with Java8 but will not run on Java7 runtime.
+
+To prevent this, add this line to your '.buckconfig.local' in the Gerrit
+source root directory when working on stable branches:
+
+----
+[java]
+  extra_arguments = -Xbootclasspath/p:/usr/lib64/jvm/java-1.7.0-openjdk-1.7.0/jre/lib/rt.jar
+----
+
+With this in place, methods that were added only in Java8 in runtime library,
+would be correctly refused to compile by Java8:
+
+----
+$ java -version
+openjdk version "1.8.0_101"
+
+$ buck build gerrit-server:server
+/home/davido/projects/gerrit/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java:218: error: cannot find symbol
+      return Collections.emptySortedSet();
+                        ^
+  symbol:   method emptySortedSet()
+  location: class java.util.Collections
+----
+
 == Upgrading Buck
 
 The following tests should be executed, when Buck version is upgraded:
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 6df553b..0b6c474 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -36,7 +36,7 @@
 ----
 mvn archetype:generate -DarchetypeGroupId=com.google.gerrit \
     -DarchetypeArtifactId=gerrit-plugin-archetype \
-    -DarchetypeVersion=2.13.2 \
+    -DarchetypeVersion=2.14-SNAPSHOT \
     -DgroupId=com.googlesource.gerrit.plugins.testplugin \
     -DartifactId=testplugin
 ----
@@ -418,6 +418,14 @@
 +
 Update of the secondary index
 
+* `com.google.gerrit.httpd.WebLoginListener`:
++
+User login or logout interactively on the Web user interface.
+
+The event listener is under the Gerrit http package to automatically
+inherit the javax.servlet.http dependencies and allowing to influence
+the login or logout flow with additional redirections.
+
 [[stream-events]]
 == Sending Events to the Events Stream
 
@@ -1112,6 +1120,10 @@
 +
 Panel will be shown below the related info block.
 
+** `GerritUiExtensionPoint.CHANGE_SCREEN_HISTORY_RIGHT_OF_BUTTONS`:
++
+Panel will be shown in the history bar on the right side of the buttons.
+
 ** The following parameters are provided:
 *** `GerritUiExtensionPoint.Key.CHANGE_INFO`:
 +
@@ -2394,6 +2406,44 @@
 ----
 
 
+[[reviewer-suggestion]]
+== Reviewer Suggestion Plugins
+
+Gerrit provides an extension point that enables Plugins to rank
+the list of reviewer suggestion a user receives upon clicking "Add Reviewer" on
+the change screen.
+Gerrit supports both a default suggestion that appears when the user has not yet
+typed anything and a filtered suggestion that is shown as the user starts
+typing.
+Plugins receive a candidate list and can return a Set of suggested reviewers
+containing the Account.Id and a score for each reviewer.
+The candidate list is non-binding and plugins can choose to return reviewers not
+initially contained in the candidate list.
+Server administrators can configure the overall weight of each plugin using the
+weight config parameter on [addreviewer "<pluginName-exportName>"].
+
+[source, java]
+----
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+
+import java.util.Set;
+
+public class MyPlugin implements ReviewerSuggestion {
+  public Set<SuggestedReviewer> suggestReviewers(Project.NameKey project,
+      @Nullable Change.Id changeId, @Nullable String query,
+      Set<Account.Id> candidates) {
+    Set<SuggestedReviewer> suggestions = new HashSet<>();
+    // Implement your ranking logic here
+    return suggestions;
+  }
+}
+----
+
+
 == SEE ALSO
 
 * link:js-api.html[JavaScript API]
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 4959ced..bd7f6e9 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -90,22 +90,49 @@
   java -jar buck-out/gen/gerrit/gerrit.war init -d ../gerrit_testsite
 ----
 
-Accept defaults by pressing Enter until 'init' completes, or add
-the '--batch' command line option to avoid them entirely.  It is
-recommended to change the listen addresses from '*' to 'localhost' to
-prevent outside connections from contacting the development instance.
+During initialization, make two changes to the default settings:
 
-The daemon will automatically start in the background and a web
-browser will launch to the start page, enabling login via OpenID.
+* Change the listen addresses from '*' to 'localhost' to prevent outside
+  connections from contacting the development instance; and
+* Change the auth type from 'OPENID' to 'DEVELOPMENT_BECOME_ANY_ACCOUNT' to
+  allow yourself to create and act as arbitrary test accounts on your
+  development instance.
 
-Shutdown the daemon after registering the administrator account
-through the web interface:
+Continue through init until it completes. The daemon will automatically start in
+the background and a web browser will launch to the start page. From here you
+can sign in as the account created during init, register additional accounts,
+create projects, and more.
+
+When you want to shut down the daemon, simply run:
 
 ----
   ../gerrit_testsite/bin/gerrit.sh stop
 ----
 
 
+[[localdev]]
+== Working with the Local Server
+
+If you need to create additional accounts on your development instance, click
+'become' in the upper right corner, select 'Switch User', and then register
+a new account.
+
+Use the `ssh` protocol to clone from and push to the local server. For
+example, to clone a repository that you've created through the admin
+interface, run:
+
+----
+git clone ssh://username@localhost:29418/projectname
+----
+
+Then you'll be able to create changes the same way users do, with
+
+----
+git push origin HEAD:refs/for/master
+----
+
+
+
 == Testing
 
 
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index 921244f..d43c863 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -89,17 +89,15 @@
 
 To upload artifacts to a bucket the user must authenticate with a
 username and password. The username and password need to be retrieved
-from the link:https://console.developers.google.com/project/164060093628[
-Google Developers Console]:
+from the link:https://console.cloud.google.com/storage/settings?project=api-project-164060093628[
+Storage Setting in the Google Cloud Platform Console]:
 
-* In the menu on the left select `Storage` -> `Cloud Storage` >
-> `Storage access`
-* Select the `Interoperability` tab
-* If no keys are listed under `Interoperable storage access keys`, select "Create a new key"
-* Use the `Access Key` as username, and `Secret` as the password
+Select the `Interoperability` tab, and if no keys are listed under
+`Interoperable storage access keys`, select 'Create a new key'.
 
-To make the username and password known to Maven, they must be
-configured in the `~/.m2/settings.xml` file.
+Using `Access Key` as username and `Secret` as the password, add the
+configuration in the `~/.m2/settings.xml` file to make the credentials
+known to Maven:
 
 ----
   <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
@@ -143,10 +141,9 @@
 ----
 
 [NOTE]
-In case of JGit the `pom.xml` already contains a distributionManagement
-section.  Replace the existing distributionManagement section with this snippet
-in order to deploy the artifacts only in the gerrit-maven repository.
-
+In case of JGit the `pom.xml` already contains a `distributionManagement`
+section.  To deploy the artifacts to the `gerrit-maven` repository, replace
+the existing `distributionManagement` section with this snippet.
 
 * Add these two snippets to the `pom.xml` to enable the wagon provider:
 
diff --git a/Documentation/dev-release-jgit.txt b/Documentation/dev-release-jgit.txt
index f6d4d68..1a8b501 100644
--- a/Documentation/dev-release-jgit.txt
+++ b/Documentation/dev-release-jgit.txt
@@ -1,33 +1,44 @@
-= Making a Release of JGit
+= Making a Snapshot Release of JGit
 
 This step is only necessary if we need to create an unofficial JGit
 snapshot release and publish it to the
 link:https://developers.google.com/storage/[Google Cloud Storage].
 
+[[prepare-environment]]
+== Prepare the Maven Environment
+
+First, make sure you have done the necessary
+link:dev-release-deploy-config.html#deploy-configuration-settings-xml[
+configuration in Maven `settings.xml`].
+
+To apply the necessary settings in JGit's `pom.xml`, follow the instructions
+in link:dev-release-deploy-config.html#deploy-configuration-subprojects[
+Configuration for Subprojects in `pom.xml`], or apply the provided diff by
+executing the following command in the JGit workspace:
+
+----
+  git apply /path/to/gerrit/tools/jgit-snapshot-deploy-pom.diff
+----
 
 [[prepare-release]]
 == Prepare the Release
 
-Since JGit has its own release process we do not push any release tags
-for JGit. Instead we will use the output of the `git describe` as the
-version of the current JGit snapshot.
+Since JGit has its own release process we do not push any release tags. Instead
+we will use the output of `git describe` as the version of the current JGit
+snapshot.
+
+In the JGit workspace, execute the following command:
 
 ----
   ./tools/version.sh --release $(git describe)
 ----
 
-
 [[publish-release]]
 == Publish the Release
 
-* Make sure you have done the configuration needed for deployment:
-** link:dev-release-deploy-config.html#deploy-configuration-settings-xml[
-Configuration in Maven `settings.xml`]
-** link:dev-release-deploy-config.html#deploy-configuration-subprojects[
-Configuration for Subprojects in `pom.xml`]
+To deploy the new snapshot, execute the following command in the JGit
+workspace:
 
-* Deploy the new snapshot. From JGit workspace execute:
-+
 ----
   mvn deploy
 ----
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 96695db..023841c 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -83,6 +83,7 @@
 .. link:#push-stable[Push the Stable Branch]
 .. link:#push-tag[Push the Release Tag]
 .. link:#upload-documentation[Upload the Documentation]
+.. link:#finalize-release-notes[Finalize Release Notes]
 .. link:#update-issues[Update the Issues]
 .. link:#announce[Announce on Mailing List]
 . link:#increase-version[Increase Gerrit Version for Current Development]
@@ -153,7 +154,7 @@
 ----
   buck clean
   buck build --no-cache release docs
-  ./tools/maven/api.sh install
+  ./tools/maven/api.sh install <buck|bazel>
 ----
 
 * Sanity check WAR
@@ -185,13 +186,13 @@
 * Push the WAR to Maven Central:
 +
 ----
-  ./tools/maven/api.sh war_deploy
+  ./tools/maven/api.sh war_deploy <buck|bazel>
 ----
 
 * Push the plugin artifacts to Maven Central:
 +
 ----
-  ./tools/maven/api.sh deploy
+  ./tools/maven/api.sh deploy <buck|bazel>
 ----
 +
 If no artifacts are uploaded, clean the `buck-out` folder and retry:
@@ -327,15 +328,6 @@
 [[upload-documentation]]
 ==== Upload the Documentation
 
-* Build the release notes:
-+
-----
-  buck build releasenotes
-----
-
-* Extract the release notes files from the zip file generated from the previous
-step: `buck-out/gen/ReleaseNotes/html/html.zip`.
-
 * Extract the documentation files from the zip file generated from
 `buck build docs`: `buck-out/gen/Documentation/searchfree/searchfree.zip`.
 
@@ -344,6 +336,16 @@
 link:https://console.cloud.google.com/storage/browser/gerrit-documentation/?project=api-project-164060093628[
 gerrit-documentation] storage bucket.
 
+[[finalize-release-notes]]
+=== Finalize the Release Notes
+
+Upload a change on the homepage project to:
+
+* Remove 'In Development' caveat from the relevant section.
+
+* Add links to the released documentation and the .war file, and make the
+latest version bold.
+
 [[update-links]]
 ==== Update homepage links
 
@@ -370,7 +372,7 @@
 
 * Send an email to the mailing list to announce the release, consider
 including some or all of the following in the email:
-** A link to the release and the release notes (if a final release)
+** A link to the release and the release notes
 ** A link to the docs
 ** Describe the type of release (stable, bug fix, RC)
 ** Hash values (SHA1, SHA256, MD5) for the release WAR file.
diff --git a/Documentation/error-prohibited-by-gerrit.txt b/Documentation/error-prohibited-by-gerrit.txt
index 3d9bbad..3e5f23b 100644
--- a/Documentation/error-prohibited-by-gerrit.txt
+++ b/Documentation/error-prohibited-by-gerrit.txt
@@ -17,10 +17,10 @@
    link:access-control.html#category_create['Create Reference'] access
    right on `+refs/heads/*+`
 4. if you push an annotated tag without
-   link:access-control.html#category_push_annotated['Push Annotated Tag']
+   link:access-control.html#category_create_annotated['Create Annotated Tag']
    access right on `+refs/tags/*+`
 5. if you push a signed tag without
-   link:access-control.html#category_push_signed['Push Signed Tag']
+   link:access-control.html#category_create_signed['Create Signed Tag']
    access right on `+refs/tags/*+`
 6. if you push a lightweight tag without the access right link:access-control.html#category_create['Create
    Reference'] for the reference name `+refs/tags/*+`
diff --git a/Documentation/index.txt b/Documentation/index.txt
index f53463c..06a416d 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -45,6 +45,7 @@
 . link:config-hooks.html[Hooks]
 . link:config-mail.html[Mail Templates]
 . link:config-cla.html[Contributor Agreements]
+. link:config-robot-comments.html[Robot Comments]
 
 == Server Administration
 . link:install.html[Installation Guide]
diff --git a/Documentation/install-quick.txt b/Documentation/install-quick.txt
index 2623256..a8115db 100644
--- a/Documentation/install-quick.txt
+++ b/Documentation/install-quick.txt
@@ -26,14 +26,14 @@
 
 ----
   $ java -version
-  java version "1.7.0_21"
-  Java(TM) SE Runtime Environment (build 1.7.0_21-b11)
-  Java HotSpot(TM) 64-Bit Server VM (build 23.21-b01, mixed mode)
+  openjdk version "1.8.0_72"
+  OpenJDK Runtime Environment (build 1.8.0_72-b15)
+  OpenJDK 64-Bit Server VM (build 25.72-b15, mixed mode)
 ----
 
 If Java isn't installed, get it:
 
-* JDK, minimum version 1.7 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
+* JDK, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
 
 
 [[user]]
diff --git a/Documentation/install.txt b/Documentation/install.txt
index e3fb28d..86c9f9a 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -5,7 +5,7 @@
 To run the Gerrit service, the following requirements must be met on
 the host:
 
-* JDK, minimum version 1.7 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
+* JDK, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
 
 You'll also need an SQL database to house the review metadata. You have the
 choice of either using the embedded H2 or to host your own MySQL or PostgreSQL.
@@ -172,6 +172,50 @@
 the embedded Jetty server, see
 link:install-j2ee.html[J2EE installation].
 
+[[installation_on_windows]]
+== Installation on Windows
+
+If new site is going to be initialized with Bouncy Castle cryptography,
+ssh-keygen command must be available during the init phase. If you have
+link:https://git-for-windows.github.io/[Git for Windows] installed,
+start Command Prompt and temporary add directory with ssh-keygen to the
+PATH environment variable just before running init command:
+
+====
+  PATH=%PATH%;c:\Program Files\Git\usr\bin
+====
+
+Please note that the path in the above example must not be
+double-quoted.
+
+To run the daemon after site initialization execute:
+
+====
+  cd C:\MY\GERRIT\SITE
+  java.exe -jar bin\gerrit.war daemon --console-log
+====
+
+To stop the daemon press Ctrl+C.
+
+=== Install the daemon as Windows Service
+
+To install Gerrit as Windows Service use the
+link:http://commons.apache.org/proper/commons-daemon/procrun.html[Apache
+Commons Daemon Procrun].
+
+Sample install command:
+
+====
+  prunsrv.exe //IS//Gerrit --DisplayName="Gerrit Code Review" --Startup=auto ^
+        --Jvm="C:\Program Files\Java\jre1.8.0_65\bin\server\jvm.dll" ^
+        --Classpath=C:\MY\GERRIT\SITE\bin\gerrit.war ^
+        --LogPath=C:\MY\GERRIT\SITE\logs ^
+        --StartPath=C:\MY\GERRIT\SITE ^
+        --StartMode=jvm --StopMode=jvm ^
+        --StartClass=com.google.gerrit.launcher.GerritLauncher --StartMethod=daemonStart ^
+        --StopClass=com.google.gerrit.launcher.GerritLauncher --StopMethod=daemonStop ^
+        ++DependsOn=postgresql-x64-9.4
+====
 
 [[customize]]
 == Site Customization
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 7a724f7..72fe717 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -70,8 +70,8 @@
 commands:
 
 ----
-  $ git fetch origin refs/meta/config:config
-  $ git checkout config
+  $ git fetch ssh://localhost:29418/project refs/meta/config
+  $ git checkout FETCH_HEAD
   $ git log project.config
 ----
 
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 9bf6842..c0af651 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -470,11 +470,16 @@
 link:user-review-ui.html#project-branch-topic[change screen].
 
 It is also possible to link:user-upload.html#topic[set a topic on
-push].
+push], either by appending `%topic=...` to the ref name or through
+the use of the command line flag `--push-option`, aliased to `-o`,
+followed by `topic=...`.
 
 .Set Topic on Push
 ----
   $ git push origin HEAD:refs/for/master%topic=multi-master
+
+  // this is the same as:
+  $ git push origin HEAD:refs/heads/master -o topic=multi-master
 ----
 
 [[drafts]]
@@ -639,6 +644,23 @@
 +
 Email notifications are disabled.
 
+- [[default-base-for-merges]]`Default Base For Merges`:
++
+This setting controls which base should be pre-selected in the
+`Diff Against` drop-down list when the change screen is opened for a
+merge commit.
++
+** `Auto Merge`:
++
+Pre-selects `Auto Merge` in the `Diff Against` drop-down list when the
+change screen is opened for a merge commit.
++
+** `First Parent`:
++
+Pre-selects `Parent 1` in the `Diff Against` drop-down list when the
+change screen is opened for a merge commit.
++
+
 - [[diff-view]]`Diff View`:
 +
 Whether the Side-by-Side diff view or the Unified diff view should be
diff --git a/Documentation/project-configuration.txt b/Documentation/project-configuration.txt
index d71d19a..2703e4e 100644
--- a/Documentation/project-configuration.txt
+++ b/Documentation/project-configuration.txt
@@ -117,6 +117,13 @@
 succeed if there is no path conflict.  A path conflict occurs when
 the same file has also been changed on the other side of the merge.
 
+[[rebase_always]]
+* Rebase Always
++
+Basically, the same as Rebase If Necesary, but it creates a new patchset even if
+fast forward is possible. In this regard, it's similar to Cherry Pick, but with
+the important distinction that Rebase Always does not ignore dependencies.
+
 [[content_merge]]
 If `Allow content merges` is enabled, Gerrit will try
 to do a content merge when a path conflict occurs.
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 61ea582..07a3d78 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -132,7 +132,7 @@
         },
         "refs/tags/*": {
           "permissions": {
-            "pushSignedTag": {
+            "createSignedTag": {
               "rules": {
                 "53a4f647a89ea57992571187d8025f830625192a": {
                   "action": "ALLOW"
@@ -142,7 +142,7 @@
                 }
               }
             },
-            "pushTag": {
+            "createTag": {
               "rules": {
                 "53a4f647a89ea57992571187d8025f830625192a": {
                   "action": "ALLOW"
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 77ca75a..47030cf 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -396,7 +396,7 @@
   HTTP/1.1 204 No Content
 ----
 
-If the account was already inactive the response is "`404 Not Found`".
+If the account was already inactive the response is "`409 Conflict`".
 
 [[get-http-password]]
 === Get HTTP Password
@@ -1213,6 +1213,7 @@
     "size_bar_in_change_table": true,
     "review_category_strategy": "ABBREV",
     "mute_common_path_prefixes": true,
+    "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
         "url": "#/dashboard/self",
@@ -1381,7 +1382,8 @@
     "show_tabs": true,
     "show_whitespace_errors": true,
     "syntax_highlighting": true,
-    "tab_size": 8
+    "tab_size": 8,
+    "font_size": 12
   }
 ----
 
@@ -1412,7 +1414,8 @@
     "show_tabs": true,
     "show_whitespace_errors": true,
     "syntax_highlighting": true,
-    "tab_size": 8
+    "tab_size": 8,
+    "font_size": 12
   }
 ----
 
@@ -1436,7 +1439,8 @@
     "show_tabs": true,
     "show_whitespace_errors": true,
     "syntax_highlighting": true,
-    "tab_size": 8
+    "tab_size": 8,
+    "font_size": 12
   }
 ----
 
@@ -2208,6 +2212,8 @@
 If true the line numbers are hidden.
 |`tab_size`                    ||
 Number of spaces that should be used to display one tab.
+|`font_size`                    ||
+Default font size in pixels for change to be displayed in the diff view.
 |'hide_empty_pane'             |not set if `false`|
 Whether empty panes should be hidden. The left pane is empty when a
 file was added; the right pane is empty when a file was deleted.
@@ -2268,6 +2274,8 @@
 True if the line numbers should be hidden.
 |`tab_size`                    |optional|
 Number of spaces that should be used to display one tab.
+|`font_size`                   |optional|
+Default font size in pixels for change to be displayed in the diff view.
 |`line_wrapping`               |optional|
 Whether to enable line wrapping or not.
 |===========================================
@@ -2440,8 +2448,6 @@
 link:rest-api-config.html#download-info[DownloadInfo].
 |`download_command`             ||
 The type of download command the user prefers to use.
-|`copy_self_on_email`           |not set if `false`|
-Whether to CC me on comments I write.
 |`date_format`                  ||
 The format to display the date in.
 Allowed values are `STD`, `US`, `ISO`, `EURO`, `UK`.
@@ -2450,21 +2456,21 @@
 Allowed values are `HHMM_12`, `HHMM_24`.
 |`relative_date_in_change_table`|not set if `false`|
 Whether to show relative dates in the changes table.
+|`diff_view`                    ||
+The type of diff view to show.
+Allowed values are `SIDE_BY_SIDE`, `UNIFIED_DIFF`.
 |`size_bar_in_change_table`     |not set if `false`|
 Whether to show the change sizes as colored bars in the change table.
 |`legacycid_in_change_table`    |not set if `false`|
 Whether to show change number in the change table.
+|`review_category_strategy`     ||
+The strategy used to displayed info in the review category column.
+Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
 |`mute_common_path_prefixes`    |not set if `false`|
 Whether to mute common path prefixes in file names in the file table.
 |`signed_off_by`                |not set if `false`|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
-|`review_category_strategy`     ||
-The strategy used to displayed info in the review category column.
-Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
-|`diff_view`                    ||
-The type of diff view to show.
-Allowed values are `SIDE_BY_SIDE`, `UNIFIED_DIFF`.
 |`my`                           ||
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
@@ -2477,6 +2483,10 @@
 their own comments. On `DISABLED` the user will not receive any email
 notifications from Gerrit.
 Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+|`default_base_for_merges`      ||
+The base which should be pre-selected in the 'Diff Against' drop-down
+list when the change screen is opened for a merge commit.
+Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
 |============================================
 
 [[preferences-input]]
@@ -2498,8 +2508,6 @@
 The type of download URL the user prefers to use.
 |`download_command`             |optional|
 The type of download command the user prefers to use.
-|`copy_self_on_email`           |optional|
-Whether to CC me on comments I write.
 |`date_format`                  |optional|
 The format to display the date in.
 Allowed values are `STD`, `US`, `ISO`, `EURO`, `UK`.
@@ -2508,21 +2516,21 @@
 Allowed values are `HHMM_12`, `HHMM_24`.
 |`relative_date_in_change_table`|optional|
 Whether to show relative dates in the changes table.
+|`diff_view`                    |optional|
+The type of diff view to show.
+Allowed values are `SIDE_BY_SIDE`, `UNIFIED_DIFF`.
 |`size_bar_in_change_table`     |optional|
 Whether to show the change sizes as colored bars in the change table.
 |`legacycid_in_change_table`    |optional|
 Whether to show change number in the change table.
+|`review_category_strategy`     |optional|
+The strategy used to displayed info in the review category column.
+Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
 |`mute_common_path_prefixes`    |optional|
 Whether to mute common path prefixes in file names in the file table.
 |`signed_off_by`                |optional|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
-|`review_category_strategy`     |optional|
-The strategy used to displayed info in the review category column.
-Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
-|`diff_view`                    |optional|
-The type of diff view to show.
-Allowed values are `SIDE_BY_SIDE`, `UNIFIED_DIFF`.
 |`my`                           |optional|
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
@@ -2535,6 +2543,10 @@
 their own comments. On `DISABLED` the user will not receive any email
 notifications from Gerrit.
 Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+|`default_base_for_merges`      |optional|
+The base which should be pre-selected in the 'Diff Against' drop-down
+list when the change screen is opened for a merge commit.
+Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
 |============================================
 
 [[query-limit-info]]
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index b8c571c..7724ceb 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -301,6 +301,12 @@
     link:user-search.html#reviewedby[reviewedby:self].
 --
 
+[[submittable]]
+--
+* `SUBMITTABLE`: include the `submittable` field in link:#change-info[ChangeInfo],
+  which can be used to tell if the change is reviewed and ready for submit.
+--
+
 [[web-links]]
 --
 * `WEB_LINKS`: include the `web_links` field in link:#commit-info[CommitInfo],
@@ -511,6 +517,61 @@
   }
 ----
 
+[[create-merge-patch-set-for-change]]
+=== Create Merge Patch Set For Change
+--
+'POST /changes/link:#change-id[\{change-id\}]/merge'
+--
+
+Update an existing change by using a
+link:#merge-patch-set-input[MergePatchSetInput] entity.
+
+Gerrit will create a merge commit based on the information of
+MergePatchSetInput and add a new patch set to the change corresponding
+to the new merge commit.
+
+.Request
+----
+  POST /changes/test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc/merge  HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "source": "refs/12/1234/1"
+  }
+----
+
+As response a link:#change-info[ChangeInfo] entity with current revision is
+returned that describes the resulting change.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc",
+    "project": "test",
+    "branch": "master",
+    "hashtags": [],
+    "change_id": "Ic5466d107c5294414710935a8ef3b0180fb848dc",
+    "subject": "Merge dev_branch into master",
+    "status": "NEW",
+    "created": "2016-09-23 18:08:53.238000000",
+    "updated": "2016-09-23 18:09:25.934000000",
+    "submit_type": "MERGE_IF_NECESSARY",
+    "mergeable": true,
+    "insertions": 5,
+    "deletions": 0,
+    "_number": 72,
+    "owner": {
+      "_account_id": 1000000
+    },
+    "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822"
+  }
+----
+
 [[get-change-detail]]
 === Get Change Detail
 --
@@ -824,6 +885,154 @@
   HTTP/1.1 204 No Content
 ----
 
+[[get-assignee]]
+=== Get Assignee
+--
+'GET /changes/link:#change-id[\{change-id\}]/assignee'
+--
+
+Retrieves the account of the user assigned to a change.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
+----
+
+As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
+describing the assigned account is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "_account_id": 1000096,
+    "name": "John Doe",
+    "email": "john.doe@example.com",
+    "username": "jdoe"
+  }
+----
+
+If the change has no assignee the response is "`204 No Content`".
+
+[[get-past-assignees]]
+=== Get Past Assignees
+--
+'GET /changes/link:#change-id[\{change-id\}]/past_assignees'
+--
+
+Returns a list of every user ever assigned to a change, in the order in which
+they were first assigned.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/past_assignees HTTP/1.0
+----
+
+As a response a list of link:rest-api-accounts.html#account-info[AccountInfo]
+entities is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "_account_id": 1000051,
+      "name": "Jane Doe",
+      "email": "jane.doe@example.com",
+      "username": "janed"
+    },
+    {
+      "_account_id": 1000096,
+      "name": "John Doe",
+      "email": "john.doe@example.com",
+      "username": "jdoe"
+    }
+  ]
+
+----
+
+
+[[set-assignee]]
+=== Set Assignee
+--
+'PUT /changes/link:#change-id[\{change-id\}]/assignee'
+--
+
+Sets the assignee of a change.
+
+The new assignee must be provided in the request body inside a
+link:#assignee-input[AssigneeInput] entity.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "assignee": "jdoe"
+  }
+----
+
+As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
+describing the assigned account is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "_account_id": 1000096,
+    "name": "John Doe",
+    "email": "john.doe@example.com",
+    "username": "jdoe"
+  }
+----
+
+[[delete-assignee]]
+=== Delete Assignee
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/assignee'
+--
+
+Deletes the assignee of a change.
+
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
+----
+
+As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
+describing the account of the deleted assignee is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "_account_id": 1000096,
+    "name": "John Doe",
+    "email": "john.doe@example.com",
+    "username": "jdoe"
+  }
+----
+
+If the change had no assignee the response is "`204 No Content`".
+
 [[abandon-change]]
 === Abandon Change
 --
@@ -1272,8 +1481,13 @@
 The listed changes use the same format as in
 link:#list-changes[Query Changes] with the
 link:#labels[`LABELS`], link:#detailed-labels[`DETAILED_LABELS`],
-link:#current-revision[`CURRENT_REVISION`], and
-link:#current-commit[`CURRENT_COMMIT`] options set.
+link:#current-revision[`CURRENT_REVISION`],
+link:#current-commit[`CURRENT_COMMIT`], and
+link:#submittable[`SUBMITTABLE`] options set.
+
+Standard link:#query-options[formatting options] can be specified
+with the `o` parameter, as well as the `submitted_together` specific
+option `NON_VISIBLE_CHANGES`.
 
 .Response
 ----
@@ -1553,13 +1767,19 @@
   HTTP/1.1 204 No Content
 ----
 
-[[delete-draft-change]]
-=== Delete Draft Change
+[[delete-change]]
+=== Delete Change
 --
 'DELETE /changes/link:#change-id[\{change-id\}]'
 --
 
-Deletes a draft change.
+Deletes a change.
+
+New or abandoned changes can only be deleted by administrators. The deletion of
+merged changes isn't supported at the moment. Draft changes can only be deleted
+by their owner or other users who have the permissions to view and delete
+drafts. If the draft workflow is disabled, only administrators with those
+permissions may delete draft changes.
 
 .Request
 ----
@@ -2135,9 +2355,17 @@
 
 Promotes change edit to a regular patch set.
 
+Options can be provided in the request body as a
+link:#publish-change-edit-input[PublishChangeEditInput] entity.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit:publish HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "notify": "NONE"
+  }
 ----
 
 As response "`204 No Content`" is returned.
@@ -2408,14 +2636,33 @@
 [[delete-reviewer]]
 === Delete Reviewer
 --
-'DELETE /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]'
+'DELETE /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]' +
+'POST /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/delete'
 --
 
 Deletes a reviewer from a change.
 
+Options can be provided in the request body as a
+link:#delete-reviewer-input[DeleteReviewerInput] entity.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe HTTP/1.0
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/delete HTTP/1.0
+----
+
+Please note that some proxies prohibit request bodies for DELETE
+requests. In this case, if you want to specify options, use a POST
+request:
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "notify": "NONE"
+  }
 ----
 
 .Response
@@ -2456,7 +2703,7 @@
 [[delete-vote]]
 === Delete Vote
 --
-'DELETE /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]'
+'DELETE /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]' +
 'POST /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]/delete'
 --
 
@@ -2545,6 +2792,60 @@
 Adding query parameter `links` (for example `/changes/.../commit?links`)
 returns a link:#commit-info[CommitInfo] with the additional field `web_links`.
 
+[[get-merge-list]]
+=== Get Merge List
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/mergelist'
+--
+
+Returns the list of commits that are being integrated into a target
+branch by a merge commit. By default the first parent is assumed to be
+uninteresting. By using the `parent` option another parent can be set
+as uninteresting (parents are 1-based).
+
+The list of commits is returned as a list of
+link:#commit-info[CommitInfo] entities. Web links are only included if
+the `links` option was set.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/7e30d802b890ec8d0be45b1cc2a8ef092bcfc858/mergelist HTTP/1.0
+----
+
+.Response
+----
+HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "commit": "674ac754f91e64a0efb8087e59a176484bd534d1",
+      "parents": [
+        {
+          "commit": "1eee2c9d8f352483781e772f35dc586a69ff5646",
+          "subject": "Migrate contributor agreements to All-Projects."
+        }
+      ],
+      "author": {
+        "name": "Shawn O. Pearce",
+        "email": "sop@google.com",
+        "date": "2012-04-24 18:08:08.000000000",
+        "tz": -420
+      },
+      "committer": {
+        "name": "Shawn O. Pearce",
+        "email": "sop@google.com",
+        "date": "2012-04-24 18:08:08.000000000",
+        "tz": -420
+      },
+      "subject": "Use an EventBus to manage star icons",
+      "message": "Use an EventBus to manage star icons\n\nImage widgets that need to ..."
+    }
+  ]
+----
+
 [[get-revision-actions]]
 === Get Revision Actions
 --
@@ -3176,6 +3477,63 @@
 will suggest the browser save the patch as `commitsha1.diff.base64`,
 for later processing by command line tools.
 
+If the `path` parameter is set, the returned content is a diff of the single
+file that the path refers to.
+
+[[submit-preview]]
+=== Submit Preview
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/preview_submit'
+--
+Gets a file containing thin bundles of all modified projects if this
+change was submitted. The bundles are named `${ProjectName}.git`.
+Each thin bundle contains enough to construct the state in which a project would
+be in if this change were submitted. The base of the thin bundles are the
+current target branches, so to make use of this call in a non-racy way, first
+get the bundles and then fetch all projects contained in the bundle.
+(This assumes no non-fastforward pushes).
+
+You need to give a parameter '?format=zip' or '?format=tar' to specify the
+format for the outer container. It is always possible to use tgz, even if
+tgz is not in the list of allowed archive formats.
+
+To make good use of this call, you would roughly need code as found at:
+----
+ $ curl -Lo preview_submit_test.sh http://review.example.com:8080/tools/scripts/preview_submit_test.sh
+----
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/preview_submit?zip HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Date: Tue, 13 Sep 2016 19:13:46 GMT
+  Content-Disposition: attachment; filename="submit-preview-147.zip"
+  X-Content-Type-Options: nosniff
+  Cache-Control: no-cache, no-store, max-age=0, must-revalidate
+  Pragma: no-cache
+  Expires: Mon, 01 Jan 1990 00:00:00 GMT
+  Content-Type: application/x-zip
+  Transfer-Encoding: chunked
+
+  [binary stuff]
+----
+
+In case of an error, the response is not a zip file but a regular json response,
+containing only the error message:
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Anonymous users cannot submit"
+----
+
 [[get-mergeable]]
 === Get Mergeable
 --
@@ -3611,6 +3969,102 @@
   }
 ----
 
+[[list-comments]]
+=== List Robot Comments
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/robotcomments/'
+--
+
+Lists the link:config-robot-comments.html[robot comments] of a
+revision.
+
+As result a map is returned that maps the file path to a list of
+link:#robot-comment-info[RobotCommentInfo] entries. The entries in the
+map are sorted by file path.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/robotcomments/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": [
+      {
+        "id": "TvcXrmjM",
+        "line": 23,
+        "message": "unused import",
+        "updated": "2016-02-26 15:40:43.986000000",
+        "author": {
+          "_account_id": 1000110,
+          "name": "Code Analyzer",
+          "email": "code.analyzer@example.com"
+        },
+        "robotId": "importChecker",
+        "robotRunId": "76b1375aa8626ea7149792831fe2ed85e80d9e04"
+      },
+      {
+        "id": "TveXwFiA",
+        "line": 49,
+        "message": "wrong indention",
+        "updated": "2016-02-26 15:40:45.328000000",
+        "author": {
+          "_account_id": 1000110,
+          "name": "Code Analyzer",
+          "email": "code.analyzer@example.com"
+        },
+        "robotId": "styleChecker",
+        "robotRunId": "5c606c425dd45184484f9d0a2ffd725a7607839b"
+      }
+    ]
+  }
+----
+
+[[get-robot-comment]]
+=== Get Robot Comment
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/robotcomments/link:#comment-id[\{comment-id\}]'
+--
+
+Retrieves a link:config-robot-comments.html[robot comment] of a
+revision.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/robotcomments/TvcXrmjM HTTP/1.0
+----
+
+As response a link:#robot-comment-info[RobotCommentInfo] entity is
+returned that describes the robot comment.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "TvcXrmjM",
+    "line": 23,
+    "message": "unused import",
+    "updated": "2016-02-26 15:40:43.986000000",
+    "author": {
+      "_account_id": 1000110,
+      "name": "Code Analyzer",
+      "email": "code.analyzer@example.com"
+    },
+    "robotId": "importChecker",
+    "robotRunId": "76b1375aa8626ea7149792831fe2ed85e80d9e04"
+  }
+----
+
 [[list-files]]
 === List Files
 --
@@ -4254,6 +4708,20 @@
 while posting the review.
 NOTE: To apply different tags on on different votes/comments multiple
 invocations of the REST call are required.
+|`post_submit` |not set if `false`|
+If true, this vote was made after the change was submitted.
+|===========================
+
+[[assignee-input]]
+=== AssigneeInput
+The `AssigneeInput` entity contains the identity of the user to be set as assignee.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name    ||Description
+|`assignee`     ||
+The link:rest-api-accounts.html#account-id[ID] of one account that
+should be added as assignee.
 |===========================
 
 [[blame-info]]
@@ -4340,6 +4808,9 @@
 |`mergeable`          |optional|
 Whether the change is mergeable. +
 Not set for merged changes, or if the change has not yet been tested.
+|`submittable`        |optional|
+Whether the change has been approved by the project submit rules. +
+Only set if link:#submittable[requested].
 |`insertions`         ||
 Number of inserted lines.
 |`deletions`          ||
@@ -4455,11 +4926,13 @@
 === CherryPickInput
 The `CherryPickInput` entity contains information for cherry-picking a change to a new branch.
 
-[options="header",cols="1,6"]
+[options="header",cols="1,^1,5"]
 |===========================
-|Field Name    |Description
-|`message`     |Commit message for the cherry-picked change
-|`destination` |Destination branch
+|Field Name         ||Description
+|`message`          ||Commit message for the cherry-picked change
+|`destination`      ||Destination branch
+|`parent`           |optional, defaults to 1|
+Number of the parent relative to which the cherry-pick should be considered.
 |===========================
 
 [[comment-info]]
@@ -4589,6 +5062,21 @@
 link:#web-link-info[WebLinkInfo] entities.
 |===========================
 
+[[delete-reviewer-input]]
+=== DeleteReviewerInput
+The `DeleteReviewerInput` entity contains options for the deletion of a
+reviewer.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`notify`  |optional|
+Notify handling that defines to whom email notifications should be sent
+after the reviewer is deleted. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `ALL`.
+|=======================
+
 [[delete-vote-input]]
 === DeleteVoteInput
 The `DeleteVoteInput` entity contains options for the deletion of a
@@ -4943,6 +5431,25 @@
 `simple-two-way-in-core`, `ours` or `theirs`, default will use project settings.
 |============================
 
+[[merge-patch-set-input]]
+=== MergePatchSetInput
+The `MergePatchSetInput` entity contains information about updating a new
+change by creating a new merge commit.
+
+[options="header",cols="1,^1,5"]
+|==================================
+|Field Name           ||Description
+|`subject`            |optional|
+The new subject for the change, if not specified, will reuse the current patch
+set's subject
+|`inheritParent`      |optional, default to `false`|
+Use the current patch set's first parent as the merge tip when set to `true`.
+Otherwise, use the current branch tip of the destination branch.
+|`merge`              ||
+The detail of the source commit for merge as a link:#merge-input[MergeInput]
+entity.
+|==================================
+
 [[move-input]]
 === MoveInput
 The `MoveInput` entity contains information for moving a change to a new branch.
@@ -4974,6 +5481,21 @@
 outcome of the fix.
 |===========================
 
+[[publish-change-edit-input]]
+=== PublishChangeEditInput
+The `PublishChangeEditInput` entity contains options for the publishing of
+change edit.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`notify`  |optional|
+Notify handling that defines to whom email notifications should be sent
+after the change edit is published. +
+Allowed values are `NONE` and `ALL`. +
+If not set, the default is `ALL`.
+|=======================
+
 [[push-certificate-info]]
 === PushCertificateInfo
 The `PushCertificateInfo` entity contains information about a push
@@ -5127,6 +5649,9 @@
 |`comments`               |optional|
 The comments that should be added as a map that maps a file path to a
 list of link:#comment-input[CommentInput] entities.
+|`robot_comments`         |optional|
+The robot comments that should be added as a map that maps a file path
+to a list of link:#robot-comment-input[RobotCommentInput] entities.
 |`strict_labels`          |`true` if not set|
 Whether all labels are required to be within the user's permitted ranges
 based on access controls. +
@@ -5141,7 +5666,9 @@
 Allowed values are `DELETE`, `PUBLISH`, `PUBLISH_ALL_REVISIONS` and
 `KEEP`. All values except `PUBLISH_ALL_REVISIONS` operate only on drafts
 for a single revision. +
-If not set, the default is `DELETE`.
+Only `KEEP` is allowed when used in conjunction with `on_behalf_of`. +
+If not set, the default is `DELETE`, unless `on_behalf_of` is set, in
+which case the default is `KEEP` and any other value is disallowed.
 |`notify`                 |optional|
 Notify handling that defines to whom email notifications should be sent
 after the review is stored. +
@@ -5195,6 +5722,11 @@
 The Gerrit server may be configured to
 link:config-gerrit.html#addreviewer.maxWithoutConfirmation[require a
 confirmation] when adding a group as reviewer that has many members.
+|`notify`  |optional|
+Notify handling that defines to whom email notifications should be sent
+after the reviewer is added. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `ALL`.
 |===========================
 
 [[revision-info]]
@@ -5253,6 +5785,31 @@
 certificate was provided, it is set to an empty object.
 |===========================
 
+[[robot-comment-info]]
+=== RobotCommentInfo
+The `RobotCommentInfo` entity contains information about a robot inline
+comment.
+
+`RobotCommentInfo` has the same fields as link:#[CommentInfo].
+In addition `RobotCommentInfo` has the following fields:
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name     ||Description
+|`robot_id`     ||The ID of the robot that generated this comment.
+|`robot_run_id` ||An ID of the run of the robot.
+|`url`          |optional|URL to more information.
+|`properties`   |optional|
+Robot specific properties as map that maps arbitrary keys to values.
+|===========================
+
+[[robot-comment-input]]
+=== RobotCommentInput
+The `RobotCommentInput` entity contains information for creating an inline
+robot comment.
+
+`RobotCommentInput` has the same fields as link:#[RobotCommentInfo].
+
 [[rule-input]]
 === RuleInput
 The `RuleInput` entity contains information to test a Prolog rule.
@@ -5314,6 +5871,8 @@
 [[submit-record]]
 === SubmitRecord
 The `SubmitRecord` entity describes results from a submit_rule.
+Fields in this entity roughly correspond to the fields set by `LABELS`
+in link:#label-info[LabelInfo].
 
 [options="header",cols="1,^1,5"]
 |===========================
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index c7c0878..7246786 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -54,6 +54,14 @@
   {
     "auth": {
       "auth_type": "LDAP",
+      "use_contributor_agreements": true,
+      "contributor_agreements": [
+        {
+          "name": "Individual",
+          "description": "If you are going to be contributing code on your own, this is the one you want. You can sign this one online.",
+          "url": "static/cla_individual.html"
+        }
+      ],
       "editable_account_fields": [
         "FULL_NAME",
         "REGISTER_NEW_EMAIL"
@@ -115,7 +123,10 @@
     "gerrit": {
       "all_projects": "All-Projects",
       "all_users": "All-Users"
-      "doc_search": true
+      "doc_search": true,
+      "web_uis": [
+        "gwt"
+      ]
     },
     "sshd": {},
     "suggest": {
@@ -1226,6 +1237,9 @@
 |`use_contributor_agreements` |not set if `false`|
 Whether link:config-gerrit.html#auth.contributorAgreements[contributor
 agreements] are required.
+|`contributor_agreements`     |not set if `use_contributor_agreements` is `false`|
+List of contributor agreements as link:rest-api-accounts.html#contributor-agreement-info[
+ContributorAgreementInfo] entities.
 |`editable_account_fields`    ||
 List of account fields that are editable. Possible values are
 `FULL_NAME`, `USER_NAME` and `REGISTER_NEW_EMAIL`.
@@ -1261,6 +1275,12 @@
 is used for Git over HTTP/HTTPS]. Only set if
 link:config-gerrit.html#auth.type[authentication type] is is `LDAP` or
 `LDAP_BIND`.
+|`git_basic_auth_policy`      |optional|
+The link:config-gerrit.html#auth.gitBasicAuthPolicy[policy] to authenticate
+Git over HTTP and REST API requests when
+link:config-gerrit.html#auth.type[authentication type] is `LDAP` and
+link:config-gerrit.html#auth.gitBasicAuth[basic authentication] is set to true.
+Can be `HTTP`, `LDAP` or `HTTP_LDAP`.
 |==========================================
 
 [[cache-info]]
@@ -1458,6 +1478,9 @@
 |`report_bug_text`   |optional, not set if default|
 link:config-gerrit.html#gerrit.reportBugText[Display text for report
 bugs link].
+|`web_uis`           ||
+List of web UIs supported by the HTTP server. Possible values are `GWT`
+and `POLYGERRIT`.
 |=================================
 
 [[hit-ration-info]]
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index b04898e..f1f1654 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -321,6 +321,20 @@
 +
 Same as <<status,status:'STATE'>>.
 
+is:submittable::
++
+True if the change is submittable according to the submit rules for
+the project, for example if all necessary labels have been voted on.
++
+This operator only takes into account one change at a time, not any
+related changes, and does not guarantee that the submit button will
+appear for matching changes. To check whether a submit button appears,
+use the
+link:rest-api-changes.html#get-revision-actions[Get Revision Actions]
+API.
++
+Equivalent to <<submittable,submittable:ok>>.
+
 [[mergeable]]
 is:mergeable::
 +
@@ -394,6 +408,15 @@
 'COMMITTER' may be the committer's exact email address, or part of the name or
 email address.
 
+[[submittable]]
+submittable:'SUBMIT_STATUS'::
++
+Changes having the given submit record status after applying submit
+rules. Valid statuses are in the `status` field of
+link:rest-api-changes.html#submit-record[SubmitRecord]. This operator
+only applies to the top-level status; individual label statuses can be
+searched link:#labels[by label].
+
 
 == Argument Quoting
 
@@ -448,8 +471,10 @@
   ('user=' or 'group=').  If an LDAP group is being referenced make
   sure to use 'ldap/<groupname>'.
 
-A label name must be followed by a score, or an operator and a score.
-The easiest way to explain this is by example.
+A label name must be followed by either a score with optional operator,
+or a label status. The easiest way to explain this is by example.
++
+First, some examples of scores with operators:
 
 `label:Code-Review=2`::
 `label:Code-Review=+2`::
@@ -473,8 +498,20 @@
 `label:Code-Review>=1`::
 +
 Matches changes with either a +1, +2, or any higher score.
++
+Instead of a numeric vote, you can provide a label status corresponding
+to one of the fields in the
+link:rest-api-changes.html#submit-record[SubmitRecord] REST API entity.
+
+`label:Non-Author-Code-Review=need`::
++
+Matches changes where the submit rules indicate that a label named
+`Non-Author-Code-Review` is needed. (See the
+link:prolog-cookbook.html#NonAuthorCodeReview[Prolog Cookbook] for how
+this label can be configured.)
 
 `label:Code-Review=+2,aname`::
+`label:Code-Review=ok,aname`::
 +
 Matches changes with a +2 code review where the reviewer or group is aname.
 
@@ -482,6 +519,14 @@
 +
 Matches changes with a +2 code review where the reviewer is jsmith.
 
+`label:Code-Review=+2,user=owner`::
+`label:Code-Review=ok,user=owner`::
+`label:Code-Review=+2,owner`::
+`label:Code-Review=ok,owner`::
++
+The special "owner" parameter corresponds to the change owner.  Matches
+all changes that have a +2 vote from the change owner.
+
 `label:Code-Review=+1,group=ldap/linux.workflow`::
 +
 Matches changes with a +1 code review where the reviewer is in the
@@ -492,14 +537,17 @@
 Matches changes with either a -1, -2, or any lower score.
 
 `is:open label:Code-Review+2 label:Verified+1 NOT label:Verified-1 NOT label:Code-Review-2`::
+`is:open label:Code-Review=ok label:Verified=ok`::
 +
-Matches changes that are ready to be submitted.
+Matches changes that are ready to be submitted according to one common
+label configuration. (For a more general check, use
+link:#submittable[submittable:ok].)
 
 `is:open (label:Verified-1 OR label:Code-Review-2)`::
+`is:open (label:Verified=reject OR label:Code-Review:reject)`::
 +
 Changes that are blocked from submission due to a blocking score.
 
-
 == Magical Operators
 
 Most of these operators exist to support features of Gerrit Code
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index ca79b93..a0803d6 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -19,11 +19,17 @@
 user must authenticate via HTTP/HTTPS.
 
 When link:config-gerrit.html#auth.gitBasicAuth[gitBasicAuth] is enabled,
-the user is authenticated using standard BasicAuth and credentials validated
-using the randomly generated HTTP password on the `HTTP Password` tab
-in the user settings page or against LDAP when configured for the Gerrit Web UI.
+the user is authenticated using standard BasicAuth. Depending on the value of
+link:#auth.gitBasicAuthPolicy[auth.gitBasicAuthPolicy], credentials are
+validated using:
 
-When gitBasicAuth is not configured, the user's HTTP credentials can be
+* The randomly generated HTTP password on the `HTTP Password` tab
+  in the user settings page if `gitBasicAuthPolicy` is `HTTP`.
+* The LDAP password if `gitBasicAuthPolicy` is `LDAP`
+* Both, the HTTP and the LDAP passwords (in this order) if `gitBasicAuthPolicy`
+  is `HTTP_LDAP`.
+
+When gitBasicAuthPolicy is not `LDAP`, the user's HTTP credentials can be
 accessed within Gerrit by going to `Settings`, and then accessing the `HTTP
 Password` tab.
 
@@ -176,12 +182,16 @@
 
 To include a short tag associated with all of the changes in the
 same group, such as the local topic branch name, append it after
-the destination branch name. In this example the short topic tag
-'driver/i42' will be saved on each change this push creates or
+the destination branch name or add it with the command line flag
+`--push-option`, aliased to `-o`. In this example the short topic
+tag 'driver/i42' will be saved on each change this push creates or
 updates:
 
 ----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%topic=driver/i42
+
+  // this is the same as:
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental -o topic=driver/i42
 ----
 
 [[message]]
@@ -399,11 +409,11 @@
 link:access-control.html#category_push_direct['Push'] with the
 'Force' option ticked.
 
-To push annotated tags, the `Push Annotated Tag` project right must
+To push annotated tags, the `Create Annotated Tag` project right must
 be granted to one (or more) of the user's groups.  There is only
 one level of access in this category.
 
-Project owners may wish to grant themselves `Push Annotated Tag`
+Project owners may wish to grant themselves `Create Annotated Tag`
 only at times when a new release is being prepared, and otherwise
 grant nothing at all.  This ensures that accidental pushes don't
 make undesired changes to the public repository.
@@ -452,6 +462,23 @@
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%base=commit-id1,base=commit-id2
 ----
 
+[[merged]]
+=== Creating Changes for Merged Commits
+
+Normally, changes are only created for commits that have not yet
+been merged into the branch. In some cases, you may want to review a
+change that has already been merged. A new change for a merged commit
+can be created by using the '%merged' argument:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common my-merged-commit:refs/for/master%merged
+----
+
+This only creates one merged change at a time, corresponding to
+exactly `my-merged-commit`. It doesn't walk all of history up to that
+point, which could be slow and create lots of unintended new changes.
+To create multiple new changes, run push multiple times.
+
 
 == repo upload
 
diff --git a/README.md b/README.md
index 020602f..e0ffd53 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,8 @@
 [Gerrit](https://www.gerritcodereview.com) is a code review and project
 management tool for Git based projects.
 
+[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-master/)
+
 ## Objective
 
 Gerrit makes reviews easier by showing changes in a side-by-side display,
@@ -69,5 +71,21 @@
 
         yum clean all && yum install gerrit-<version>[-<release>]
 
+On Fedora run:
+
+        dnf clean all && dnf install gerrit-<version>[-<release>]
+
+## Use pre-built Gerrit images on Docker
+
+Docker images of Gerrit are available on [DockerHub](https://hub.docker.com/u/gerritforge/)
+
+To run a CentOS 7 based Gerrit image:
+
+        docker run -p 8080:8080 gerritforge/gerrit-centos7[:version]
+
+To run a Ubuntu 15.04 based Gerrit image:
+
+        docker run -p 8080:8080 gerritforge/gerrit-ubuntu15.04[:version]
+
 _NOTE: release is optional. Last released package of the version is installed if the release
 number is omitted._
diff --git a/ReleaseNotes/BUILD b/ReleaseNotes/BUILD
new file mode 100644
index 0000000..9bf572e
--- /dev/null
+++ b/ReleaseNotes/BUILD
@@ -0,0 +1,27 @@
+load("//tools/bzl:asciidoc.bzl", "release_notes_attributes")
+load("//tools/bzl:asciidoc.bzl", "genasciidoc")
+load("//tools/bzl:asciidoc.bzl", "genasciidoc_zip")
+
+
+SRCS = glob(['*.txt'])
+
+
+genasciidoc(
+  name = 'ReleaseNotes',
+  srcs = SRCS,
+  attributes = release_notes_attributes(),
+  backend = 'html5',
+  searchbox = False,
+  resources = False,
+  visibility = ["//visibility:public"],
+)
+
+genasciidoc_zip(
+  name = "html",
+  srcs = SRCS,
+  attributes = release_notes_attributes(),
+  backend = 'html5',
+  searchbox = False,
+  resources = False,
+  visibility = ["//visibility:public"],
+)
diff --git a/ReleaseNotes/ReleaseNotes-2.12.1.txt b/ReleaseNotes/ReleaseNotes-2.12.1.txt
index e746d6e..8f94810 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.1.txt
@@ -1,228 +1,5 @@
 = Release notes for Gerrit 2.12.1
 
-Gerrit 2.12.1 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.12.1.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.12.1.war]
-
-Gerrit 2.12.1 includes the bug fixes done with
-link:ReleaseNotes-2.11.6.html[Gerrit 2.11.6] and
-link:ReleaseNotes-2.11.7.html[Gerrit 2.11.7]. These bug fixes are *not*
-listed in these release notes.
-
-== Schema Upgrade
-
-*WARNING:* This version includes a manual schema upgrade when upgrading
-from 2.12.
-
-When upgrading a site that is already running version 2.12, the `patch_sets`
-table must be manually migrated using the `gerrit gsql` SSH command or the
-`gqsl` site program.
-
-For the default H2 database, execute the command:
-
-----
-  alter table patch_sets modify push_certficate clob;
-----
-
-For MySQL, execute the command:
-
-----
-  alter table patch_sets modify push_certficate text;
-----
-
-For PostgreSQL, execute the command:
-
-----
-  alter table patch_sets alter column push_certficate type text;
-----
-
-For other database types, execute the appropriate equivalent command.
-
-Note that the misspelled `push_certficate` is the actual name of the
-column.
-
-When upgrading from a version earlier than 2.12, this manual step is not
-necessary and should be omitted.
-
-
-== Bug Fixes
-
-=== General
-
-* Fix column type for signed push certificates.
-+
-The column type `VARCHAR(255)` was too small, preventing some PGP push
-certificates from being stored.
-
-* Add the `DRAFT_COMMENTS` option to the list changes REST API endpoint
-and mark it as deprecated.
-+
-It was removed in version 2.12 because it's not needed any more by the UI,
-but this caused failures for clients that still use it.
-+
-Now it is added back, although it does not do anything and is marked as
-deprecated.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3669[Issue 3669]:
-Fix schema migration when migrating to 2.12.x directly from a version
-earlier than 2.11.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3733[Issue 3733]:
-Correctly detect symlinked log directory on startup.
-+
-If `$site_path/logs` was a symlink, the server would not start.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3871[Issue 3871]:
-Throw an explicit exception when failing to load a change from the database.
-+
-If a change could not be loaded from the database, for example if it was
-manually removed from the changes table but references to it were remaining
-in other tables, a null change was returned which would then lead to an
-'Internal Server Error' that was difficult to track down. Now an error is
-raised earlier which will help administrators to find the root cause.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3743[Issue 3743]:
-Use submitter identity as committer when using 'Rebase if Necessary' merge
-strategy.
-+
-When submitting a change that required rebase, the committer was being
-set to 'Gerrit Code Review' instead of the name of the submitter.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3758[Issue 3758]:
-Fix serving of static resources when deployed in application container.
-+
-When deployed in a container, for example Tomcat, it was not possible to
-load the UI because static content could not be loaded from the WAR file.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3790[Issue 3790]:
-When deployed in a container, for example Tomcat, the 'Documentation' menu
-was missing.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3786[Issue 3786]:
-Fix SQL statement syntax in schema migration.
-+
-An extra semicolon was preventing migration from 2.11.x to 2.12 when using
-an Oracle database.
-
-* Send email using email queue instead of the default queue.
-+
-Some emails sent asynchronously were already being sent using that queue
-but some were not. This was confusing for a gerrit administrator because
-if there is a build up of `send-email` tasks in the queue, he would
-think that increasing `sendemail.threadPoolSize` would help but it did not
-because some of the email were sent using the default queue which is
-configurable using `execution.defaultThreadPoolSize`.
-
-* Fix XSRF token cookie to honor `auth.cookieSecure` setting.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3767[Issue 3767]:
-Fix replication of first patch set for new changes.
-+
-When new changes were pushed from the command line, the first patch
-set did not get replicated to destinations.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3771[Issue 3771]:
-Remove `index.defaultMaxClauseCount` configuration option.
-+
-When `index.maxTerms` was either not set (thus no limit) or set to a value
-higher than `index.defaultMaxClauseCount` it was possible that viewing the
-related changes tab could cause a 'Too many clauses' error for changes that
-have a lot of related changes.
-+
-The `index.defaultMaxClauseCount` configuration option is removed, and the
-existing `index.maxTerms` is reused. The default value of `index.maxTerms`
-is reduced from 'no limit' to 1024.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3919[Issue 3919]:
-Explicitly set parent project to 'All-Projects' when a project is created
-without giving the parent.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3948[Issue 3948]:
-Fix submit of project parent updates on `refs/meta/config`.
-+
-When submitting a change on `refs/meta/config` to update a project's parent,
-the error 'The change must be submitted by a Gerrit administrator' was being
-displayed even when the submitter was an admin. The submit was successful
-when clicking 'Submit' a second time.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3811[Issue 3811]:
-Fix submittability of merge commits that resolve merge conflicts.
-+
-If a series of changes contained a change that conflicted with the destination
-branch, but the conflict was solved by a merge commit at the tip of the
-series, the series was not submittable.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3883[Issue 3883]:
-Respect the `core.commentchar` setting from `.gitconfig` in `commit-msg` hook.
-
-=== UI
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3894[Issue 3894]:
-Fix display of 'Related changes' after change is rebased in web UI:
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3071[Issue 3071]:
-Fix display of submodule differences in side-by-side view.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3718[Issue 3718]:
-Hide avatar images when no avatars are available.
-+
-The UI was showing a transparent empty image with a border.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3731[Issue 3731]:
-Fix syntax higlighting of tcl files.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3863[Issue 3863]:
-Fix display of active row marker in tag list.
-+
-Clicking on one of the rows would cause the tag name to disappear.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1207[Issue 1207]:
-Fix keyboard shortcuts for non-US keyboards on side-by-side diff screen.
-+
-The forward/backward navigation keys `[` and `]` only worked on keyboards where
-these characters could be typed without using any modifier key (like CTRL, ALT,
-etc..).
-+
-Note that the problem still exists on the unified diff screen.
-
-* Improve tooltip on 'Submit' button when 'Submit whole topic' is enabled
-and the topic can't be submitted due to some changes not being ready.
-
-=== Plugins
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3821[Issue 3821]:
-Fix repeated reloading of plugins when running on OpenJDK 8.
-+
-OpenJDK 8 uses nanotime precision for file modification time on systems that
-are POSIX 2008 compatible. This leads to precision incompatibility when
-comparing the plugin's JAR file timestamp, resulting in the plugin being
-reloaded every minute.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3741[Issue 3741]:
-Fix handling of merge validation exceptions emitted by plugins.
-+
-If a plugin raised an exception, it was reported to the user as 'Change is
-new', rather than 'Missing dependency'.
-
-* Allow plugins to get the caller in merge validation requests.
-+
-Plugins that implement the `MergeValidationListener` interface now get the
-caller (the user who initiated the merge) in the `onPreMerge` method.
-+
-Existing plugins that implement this interface must be adapted to the new
-method signature.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3892[Issue 3892]:
-Allow plugins to suggest reviewers based on either change or project
-resources.
-
-=== Documentation
-
-* Update documentation of `commentlink` to reflect changed search URL.
-
-* Add missing documentation of valid `database.type` values.
-
-== Upgrades
-
-* Upgrade JGit to 4.1.2.201602141800-r.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md#2.12.1[
+Release notes for Gerrit 2.12.1].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.2.txt b/ReleaseNotes/ReleaseNotes-2.12.2.txt
index 8292eb5..35682ed 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.2.txt
@@ -1,70 +1,5 @@
 = Release notes for Gerrit 2.12.2
 
-Gerrit 2.12.2 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.12.2.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.12.2.war]
-
-== Schema Upgrade
-
-*WARNING:* There are no schema changes from link:ReleaseNotes-2.12.1.html[
-2.12.1] but a manual schema upgrade is necessary when upgrading from 2.12.
-
-When upgrading a site that is already running version 2.12, the `patch_sets`
-table must be manually migrated using the `gerrit gsql` SSH command or the
-`gqsl` site program.
-
-For the default H2 database, execute the command:
-
-----
-  alter table patch_sets modify push_certficate clob;
-----
-
-For MySQL, execute the command:
-
-----
-  alter table patch_sets modify push_certficate text;
-----
-
-For PostgreSQL, execute the command:
-
-----
-  alter table patch_sets alter column push_certficate type text;
-----
-
-For other database types, execute the appropriate equivalent command.
-
-Note that the misspelled `push_certficate` is the actual name of the
-column.
-
-When upgrading from a version earlier than 2.12, or from 2.12.1 having already
-done the migration, this manual step is not necessary and should be omitted.
-
-
-== Bug Fixes
-
-* Upgrade Apache commons-collections to version 3.2.2.
-+
-Includes a fix for a link:https://issues.apache.org/jira/browse/COLLECTIONS-580[
-remote code execution exploit].
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3919[Issue 3919]:
-Explicitly set parent project to 'All-Projects' when a project is created
-without giving the parent.
-
-* Don't add message twice on abandon or restore via ssh review command.
-+
-When abandoning or reviewing a change via the ssh `review` command, and
-providing a message with the `--message` option, the message was added to
-the change twice.
-
-* Clear the input box after cancelling add reviewer action.
-+
-When the action was cancelled, the content of the input box was still
-there when opening it again.
-
-* Fix internal server error when aborting ssh command.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3969[Issue 3969]:
-Fix internal server error when submitting a change with 'Rebase If Necessary'
-strategy.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md#2.12.2[
+Release notes for Gerrit 2.12.2].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.3.txt b/ReleaseNotes/ReleaseNotes-2.12.3.txt
index f51d739..06b18da 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.3.txt
@@ -1,113 +1,5 @@
 = Release notes for Gerrit 2.12.3
 
-Gerrit 2.12.3 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.12.3.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.12.3.war]
-
-Gerrit 2.12.3 includes the bug fixes done with
-link:ReleaseNotes-2.11.8.html[Gerrit 2.11.8] and
-link:ReleaseNotes-2.11.9.html[Gerrit 2.11.9]. These bug fixes are *not*
-listed in these release notes.
-
-== Schema Upgrade
-
-*WARNING:* There are no schema changes from link:ReleaseNotes-2.12.2.html[
-2.12.2] but a manual schema upgrade is necessary when upgrading from 2.12.
-
-When upgrading a site that is already running version 2.12, the `patch_sets`
-table must be manually migrated using the `gerrit gsql` SSH command or the
-`gqsl` site program.
-
-For the default H2 database, execute the command:
-
-----
-  alter table patch_sets modify push_certficate clob;
-----
-
-For MySQL, execute the command:
-
-----
-  alter table patch_sets modify push_certficate text;
-----
-
-For PostgreSQL, execute the command:
-
-----
-  alter table patch_sets alter column push_certficate type text;
-----
-
-For other database types, execute the appropriate equivalent command.
-
-Note that the misspelled `push_certficate` is the actual name of the
-column.
-
-When upgrading from a version earlier than 2.12, or from 2.12.1 or 2.12.2
-having already done the migration, this manual step is not necessary and
-should be omitted.
-
-
-== Bug Fixes
-
-* Fix SSL security issue in the SMTP email relay.
-+
-The hostname of the SSL socket was not verified. This made the read
-from the socket insecure since without verifying the hostname it may
-be link:https://www.cs.utexas.edu/~shmat/shmat_ccs12.pdf[vulnerable
-to a man-in-the-middle attack].
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3895[Issue 3895]:
-Fix failure to submit with 'Rebase if Necessary' after changes were reordered
-with interactive rebase.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4052[Issue 4052]:
-Fix failure to start server after upgrade from version 2.9.4.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3891[Issue 3891]:
-Fix query with `label:` operator and zero value.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4112[Issue 4112]:
-Fix failure to submit changes caused by empty user edit ref.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4087[Issue 4087]:
-Fix failure to submit change when a branch is created on the change ref.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4155[Issue 4155]:
-Fix tags REST API to correctly return all tags.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4154[Issue 4154]:
-Add support for `.team` and several more TLDs in email address validation.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4163[Issue 4163]:
-Prevent removal of non-voting reviewers on submit of change.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=2647[Issue 2647]:
-Fix usage of `CTRL-C` on change screen.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4236[Issue 4236]:
-Fix internal error when pushing an amended commit with the `%edit` option.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3426[Issue 3426]:
-Fix pushing changes with `%base` option or `newChangeForAllNotInTarget` option.
-
-* Show 'Submitted Together' tab for changes with same topic.
-
-* Improve submit button tooltip messages shown when change is not submittable.
-
-* Fix firing of the `topic-changed` hook.
-
-* Remove `--dry-run` option from the `Reindex` site program.
-+
-The implementation of the option was removed, but the option was mistakenly
-added back to the command and did not actually work.
-
-* Print proper task names in the output of the `show-queues` command.
-
-* Replication plugin: Double check if a ref is missing locally before deleting
-from remote.
-
-* Show an error message when trying to add a non-existent group to an ACL.
-
-== Updates
-
-* Update commons-validator to 1.5.1.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md#2.12.3[
+Release notes for Gerrit 2.12.3].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.4.txt b/ReleaseNotes/ReleaseNotes-2.12.4.txt
index 64252c6..8321efa 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.4.txt
@@ -1,128 +1,5 @@
 = Release notes for Gerrit 2.12.4
 
-Gerrit 2.12.4 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.12.4.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.12.4.war]
-
-== Schema Upgrade
-
-*WARNING:* There are no schema changes from link:ReleaseNotes-2.12.3.html[
-2.12.3] but a manual schema upgrade is necessary when upgrading from 2.12.
-
-When upgrading a site that is already running version 2.12, the `patch_sets`
-table must be manually migrated using the `gerrit gsql` SSH command or the
-`gqsl` site program.
-
-For the default H2 database, execute the command:
-
-----
-  alter table patch_sets modify push_certficate clob;
-----
-
-For MySQL, execute the command:
-
-----
-  alter table patch_sets modify push_certficate text;
-----
-
-For PostgreSQL, execute the command:
-
-----
-  alter table patch_sets alter column push_certficate type text;
-----
-
-For other database types, execute the appropriate equivalent command.
-
-Note that the misspelled `push_certficate` is the actual name of the
-column.
-
-When upgrading from a version earlier than 2.12, or from 2.12.1 or 2.12.2
-having already done the migration, this manual step is not necessary and
-should be omitted.
-
-== Known Issues
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4323[Issue 4323]:
-'value too long for type character varying(255)' in patch_sets table when
-migrating to schema version 108.
-+
-This error may occur under some circumstances when running the schema
-migration from an earlier version of Gerrit.
-+
-On sites where this occurs, it can be fixed with a manual schema update
-according to the comments in the issue.
-
-== Bug Fixes
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4400[Issue 4400]:
-Fix `AlreadyClosedException` in Lucene index.
-+
-If a Lucene indexing thread was interrupted by an SSH connection being
-closed, this would also close file handles being used to read the index.
-+
-Lucene queries are now executed on background threads to isolate them
-from SSH threads.
-+
-This may also reduce latency for user dashboards on a multi-core system as
-each query for the different sections can now run on separate threads and
-return results when ready.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4249[Issue 4249]:
-Fix 'Duplicate stages not allowed' error during indexing.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4238[Issue 4238]:
-Fix 'not found' error when browsing tree in gitweb.
-+
-The `refs/heads/` prefix was incorrectly being added to `HEAD`, causing a
-'404 Not Found' error.
-
-* Allow to read repositories that do not end with `.git`.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4262[Issue 4262]:
-Fix GPG push certificate for first patch set of new changes.
-+
-The GPG certificate was not being set for the first patch set of new
-changes.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4296[Issue 4296]:
-Fix internal error when a query does not contain any token.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4241[Issue 4241]:
-Fix 'Cannot format velocity template' error when sending notification emails.
-
-* Fix `sshd.idleTimeout` setting being ignored.
-+
-The `sshd.idleTimeout` setting was not being correctly set on the SSHD
-backend, causing idle sessions to not time out.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4324[Issue 4324]:
-Set the correct uploader on new patch sets created via the inline editor.
-
-* Log a warning instead of failing when invalid commentlinks are configured.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4136[Issue 4136]:
-Fix support for `HEAD` requests in the REST API.
-+
-Sending a `HEAD` request failed with '404 Not Found'.
-
-* Return proper error response when trying to confirm an email that is already
-used by another user.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4318[Issue 4318]
-Fix 'Rebase if Necessary' merge strategy to prevent introducing a duplicate
-commit when submitting a merge commit.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4332[Issue 4332]:
-Allow `local` as a valid TLD for outgoing emails.
-
-* Bypass hostname verification when `sendemail.sslVerify` is disabled.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4398[Issue 4398]:
-Replication: Consider ref visibility when scheduling replication.
-+
-It was possible for refs to be replicated to remotes despite not being
-visible to groups mentioned in the `authGroup` setting.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4036[Issue 4036]:
-Fix hanging query when using `is:watched` without authentication.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md#2.12.4[
+Release notes for Gerrit 2.12.4].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.5.txt b/ReleaseNotes/ReleaseNotes-2.12.5.txt
index 12d6870..4199fe0 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.5.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.5.txt
@@ -1,101 +1,5 @@
 = Release notes for Gerrit 2.12.5
 
-Gerrit 2.12.5 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.12.5.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.12.5.war]
-
-== Schema Upgrade
-
-*WARNING:* There are no schema changes from link:ReleaseNotes-2.12.4.html[
-2.12.4] but a manual schema upgrade is necessary when upgrading from 2.12.
-
-When upgrading a site that is already running version 2.12, the `patch_sets`
-table must be manually migrated using the `gerrit gsql` SSH command or the
-`gqsl` site program.
-
-For the default H2 database, execute the command:
-
-----
-  alter table patch_sets modify push_certficate clob;
-----
-
-For MySQL, execute the command:
-
-----
-  alter table patch_sets modify push_certficate text;
-----
-
-For PostgreSQL, execute the command:
-
-----
-  alter table patch_sets alter column push_certficate type text;
-----
-
-For other database types, execute the appropriate equivalent command.
-
-Note that the misspelled `push_certficate` is the actual name of the
-column.
-
-When upgrading from a version earlier than 2.12, or from 2.12.1 or 2.12.2
-having already done the migration, this manual step is not necessary and
-should be omitted.
-
-== Known Issues
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4323[Issue 4323]:
-'value too long for type character varying(255)' in patch_sets table when
-migrating to schema version 108.
-+
-This error may occur under some circumstances when running the schema
-migration from an earlier version of Gerrit.
-+
-On sites where this occurs, it can be fixed with a manual schema update
-according to the comments in the issue.
-
-== New Features
-
-* New preference to enable line wrapping in diff screen and inline editor.
-
-== Bug Fixes
-
-* Fix the diff and edit preference dialogs for smaller screens.
-+
-On smaller screens the options at the bottom of the dialogs would
-get cut off, making it difficult to change them.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4521[Issue 4521]:
-Fix internal server error during validation of email addresses.
-+
-When creating a new account or adding a new email address to an existing
-account, the email validation crashed.
-
-* Lucene stability improvements.
-+
-Each Lucene index is now written using a dedicated background thread. Lucene
-threads may not be cancelled, to prevent interruptions while writing.
-
-* Don't try to change username that is already set.
-+
-Since Gerrit version 2.1.4 it is not allowed to change the username once
-it has been set, and attempting to do so results in an exception.
-+
-If `ldap.accountSshUserName` is set in the `gerrit.config` using
-`${userPrincipalName.localPart}` to initialize the username from the user's
-email address, and then the email address is changed, the username gets
-resolved to something different and the account manager tried to change it.
-As a result, an exception was raised and the user could no longer log in.
-+
-Instead of trying to change the username, a warning is logged.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4006[Issue 4006]:
-Prevent search limit parameter from exceeding maximum integer value.
-
-* Fix internal server error when generating task names.
-
-* Print proper names for query tasks in the output of the `show-queue` command.
-
-* Double-check change status when auto-abandoning changes.
-+
-It was possible that changes could be updated in the time between the query
-results being returned and the change being abandoned.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md#2.12.5[
+Release notes for Gerrit 2.12.5].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.txt b/ReleaseNotes/ReleaseNotes-2.12.txt
index 84644e8..3eae5e4 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.txt
@@ -1,562 +1,5 @@
 = Release notes for Gerrit 2.12
 
-
-Gerrit 2.12 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.12.war[
-https://www.gerritcodereview.com/download/gerrit-2.12.war]
-
-== Important Notes
-
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* To use online reindexing when upgrading to 2.12.x, the server must
-first be upgraded to 2.8 (or 2.9) and then through 2.10 and 2.11 to 2.12.x. If
-reindexing will be done offline, you may ignore this warning and upgrade directly
-to 2.12.x.
-
-*WARNING:* When upgrading from version 2.8.4 or older with a site that uses
-Bouncy Castle Crypto, new versions of the libraries will be downloaded. The old
-libraries should be manually removed from site's `lib` folder to prevent the
-startup failure described in
-link:https://code.google.com/p/gerrit/issues/detail?id=3084[issue 3084].
-
-*WARNING:* The Solr secondary index is no longer supported. With this release
-the only supported secondary index is Lucene.
-
-*WARNING:* The format of the `ref-updated` event has changed. Users of the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[
-Jenkins Gerrit Trigger plugin] with jobs triggering on `ref-updated` should
-upgrade to at least
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger#GerritTrigger-Version2.15.1%28releasedSept142015%29[
-version 2.15.1]. If an upgrade of the plugin is not possible, a workaround is
-to change the branch configuration to type `Path` with a pattern like
-`refs/*/master` instead of `Plain` and `master`.
-
-
-== Release Highlights
-
-This release includes the following new features. See the sections below for
-further details.
-
-* New change submission workflows: 'Submit Whole Topic' and 'Submitted Together'.
-
-* Support for GPG Keys and signed pushes.
-
-
-== New Features
-
-=== New Change Submission Workflows
-
-* New 'Submit Whole Topic' setting.
-+
-When the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#change.submitWholeTopic[
-`change.submitWholeTopic`] setting is enabled, all changes belonging to the same
-topic will be submitted at the same time.
-+
-This setting should be considered experimental, and is disabled by default.
-
-* Submission of changes may include ancestors.
-+
-If a change is submitted that has submittable ancestor changes, those changes
-will also be submitted.
-
-* The merge queue is removed.
-+
-Changes that cannot be submitted due to missing dependencies will no longer
-enter the 'Submitted, Merge Pending' state.
-
-
-=== GPG Keys and Signed Pushes
-
-* Signed push can be enabled by setting
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#receive.enableSignedPush[
-`receive.enableSignedPush`] to true.
-+
-When a client pushes with `git push --signed`, Gerrit ensures that the push
-certificate is valid and signed with a valid public key stored in the
-`refs/meta/gpg-keys` branch of the `All-Users` repository.
-
-* When signed push is enabled, and
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#gerrit.editGpgKeys[
-`gerrit.editGpgKeys`] is set to true, users may upload their public GPG
-key via the REST API or UI.
-+
-If this setting is not enabled, GPG keys may only be added by administrators
-with direct access to the `All-Users` repository.
-
-* Administrators may also configure
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#receive.certNonceSeed[
-`receive.certNonceSeed`] and
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#receive.certNonceSlop[
-`receive.certNonceSlop`].
-
-
-=== Secondary Index
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3333[Issue 3333]:
-Support searching for changes by author and committer.
-+
-Changes are indexed by the git author and committer of the latest patch set,
-and can be searched with the `author:` and `committer:` operators.
-+
-Changes are matched on either the exact whole email address, or on parts of the
-name or email address.
-
-* Add `from:` search operator to match by owner of change or author of comments.
-
-* Add `commentby:` search operator to search by author of comments.
-
-* Change the `topic:` search operator to search by the exact topic name.
-
-* Add `intopic:` search operator to search by topics containing the search term.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3291[Issue 3291]:
-Add `has:edit` search operator to match changes that have edit revisions on them.
-
-* Allow configuration of maximum query size.
-+
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#index.maxTerms[
-`index.maxTerms`] can be set to limit the number of leaf index terms.
-
-* Expose Lucene index writers for plugins.
-+
-Plugins can now reconfigure various Lucene performance related parameters
-at runtime.
-
-* Make Lucene index writers auto-commit writers.
-+
-Plugins can now temporarily turn on auto-committing in situations where it makes
-sense to enforce all changes to be written to disk ASAP.
-
-
-=== UI
-
-==== General
-
-* Edit and diff preferences can be modified from the user preferences screen.
-+
-Previously it was only possible to edit these preferences from the actual
-diff and edit screens.
-
-* Add 'Edits' to the 'My' dashboard menu to list changes on which the user
-has an unpublished edit revision.
-
-* Support for URL aliases.
-+
-Administrators may define
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#urlAlias[
-URL aliases] to map plugin screens into the Gerrit URL namespace.
-+
-Plugins may use user-specific URL aliases to replace certain screens for certain
-users.
-
-
-==== Project Screen
-
-* New tab to list the project's tags, similar to the branch list.
-
-
-==== Inline Editor
-
-* Store and load edit preferences in git.
-+
-Edit preferences are stored and loaded to/from the `All-Users` repository.
-
-* Add 'auto close brackets' feature.
-
-* Add 'match brackets' feature.
-
-* Make the cursor blink rate customizable.
-
-* Add support for Emacs and Vim key maps.
-
-
-==== Change Screen
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3318[Issue 3318]:
-Highlight 'Reply' button if there are draft comments on any patch set.
-+
-If any patch set of the change has a draft comment by the current user,
-the 'Reply' button is highlighted.
-+
-The icons depicting draft comments are removed from the revisions drop-down
-list.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1100[Issue 1100]:
-Publish all draft comments when replying to a change.
-+
-All draft comments, including those on older patch sets, are published when
-replying to a change.
-
-* Show file size increase/decrease for binary files.
-
-* Show uploader if different from change owner.
-
-* Show push certificate status.
-
-* Show change subject as tooltip on related changes list.
-+
-This helps to identify changes when the subject is truncated in the list.
-
-
-==== Side-By-Side Diff
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3293[Issue 3293]:
-Add syntax highlighting for Puppet.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3447[Issue 3447]:
-Add syntax highlighting for VHDL.
-
-
-==== Group Screen
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1479[Issue 1479]:
-The group screen now includes an 'Audit Log' panel showing member additions,
-removals, and the user who made the change.
-
-
-=== API
-
-Several new APIs are added.
-
-==== Accounts
-
-* Suggest accounts.
-
-==== Tags
-
-* List tags.
-
-* Get tag.
-
-
-=== REST API
-
-New REST API endpoints and new options on existing endpoints.
-
-
-==== Accounts
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-accounts.html#set-username[
-Set Username]: Set the username of an account.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-accounts.html#get-detail[
-Get Account Details]: Get the details of an account.
-+
-In addition to the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-accounts.html#account-info[
-AccountInfo] fields returned by the existing
- link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-accounts.html#get-account[
-Get Account] endpoint, the new REST endpoint returns the registration date of
-the account and the timestamp of when contact information was filed for this
-account.
-
-
-==== Changes
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-changes.html#set-review[
-Set Review]: Add an option to omit duplicate comments.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-changes.html#get-safe-content[
-Download Content]: Download the content of a file from a certain revision, in a
-safe format that poses no risk for inadvertent execution of untrusted code.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-changes.html#submitted-together[
-Get Submitted Together]: Get the list of all changes that will be submitted at
-the same time as the change.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1100[Issue 1100]:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-changes.html#set-review[
-Set Review]: Add an option to publish draft comments on all revisions.
-
-==== Config
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-config.html#get-info[
-Get Server Info]: Return information about the Gerrit server configuration.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-config.html#confirm-email[
-Confirm Email]: Confirm that the user owns an email address.
-
-
-==== Groups
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-groups.html#list-group[
-List Groups]: Add option to suggest groups.
-+
-This allows group auto-completion to be used in a plugin's UI.
-
-*  link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-groups.html#get-audit-log[
-Get Audit Log]: Get the audit log of a Gerrit internal group, showing member
-additions, removals, and the user who made the change.
-
-
-==== Projects
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-projects.html#run-gc[
-Run GC]: Add `aggressive` option to specify whether or not to run an aggressive
-garbage collection.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-projects.html#list-tags[
-List Tags]: Support filtering by substring and regex, and pagination with
-`--start` and `--end`.
-
-
-=== SSH
-
-* Add support for ZLib Compression.
-+
-To enable compression use the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#sshd.enableCompression[
-`sshd.enableCompression` setting].
-
-* Add support for hmac-sha2-256 and hmac-sha2-512 as MACs.
-
-=== Plugins
-
-==== General
-
-* Gerrit client can now pass JavaScriptObjects to extension panels.
-
-* New UI extension point for header bar in change screen.
-
-* New UI extension point to password screen.
-
-* New UI extension points to project info screen.
-
-* New UI extension point for pop down buttons on change screen.
-
-* New UI extension point for buttons in header bar on change screen.
-
-* New UI extension point at bottom of the user preferences screen.
-
-* New UI extension point for the 'Included In' drop-down panel.
-+
-By implementing the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/dev-plugins.html#included-in[
-Included In interface], plugins may add entries to the 'Included In' dropdown
-menu on the change screen.
-
-* Plugins can extend Gerrit screens with GWT controls.
-
-* Plugins can add custom settings screens.
-
-* Referencing groups in `project.config`.
-+
-Plugins can refer to groups so that when they are renamed, the project
-config will also be updated in this section.
-
-* API
-
-** Allow to use `CurrentSchemaVersion`.
-
-** Allow to use `InternalChangeQuery.query()`.
-
-** Allow to use `JdbcUtil.port()`.
-
-** Allow to use GWTORM `Key` classes.
-
-
-=== Other
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3401[Issue 3401]:
-Add option to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#sendemail.allowRegisterNewEmail[
-disable registration of new email addresses].
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2061[Issue 2061]
-Add Support for `git-upload-archive`.
-+
-This allows use the standard `git archive` command to create an archive
-of the content of a repository.
-
-* Add a background job to automatically abandon inactive changes.
-+
-The
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#changeCleanup[
-changeCleanup] configuration can be set to periodically check for inactive
-changes and automatically abandon them.
-
-* Add support for the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/database-setup.html#createdb_db2[
-DB2 database].
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3441[Issue 3441]:
-Add support for the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/database-setup.html#createdb_derby[
-Apache Derby database].
-
-* Download commands plugin: Use commit IDs for download commands when change refs are hidden.
-+
-Git has a configuration option to hide refs from the initial advertisement
-(`uploadpack.hideRefs`). This option can be used to hide the change refs from
-the client. As consequence this prevented fetching changes by change ref from
-working.
-+
-Setting `download.checkForHiddenChangeRefs` in the `gerrit.config` to true
-allows the download commands plugin to check for hidden change refs.
-
-* Add a new 'Maintain Server' global capability.
-+
-Members of a group with the 'Maintain Server' capability may view caches, tasks,
-and queues, and invoke the index REST API on changes.
-
-
-== Bug Fixes
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3499[Issue 3499]:
-Fix syntax highlighting of raw string literals in go.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3643[Issue 3643]:
-Fix syntax highlighting of ES6 string templating using backticks.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3653[Issue 3653]:
-Correct timezone in sshd log after DST change.
-+
-When encountering a DST switch, the timezone wasn't updated until
-the server was reloaded.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3306[Issue 3306]:
-Allow admins to read, push and create on `refs/users/default`.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3212[Issue 3212]:
-Fix failure to run `init` when `--site-path` option is not explicitly given.
-
-* Make email validation case insensitive.
-+
-While link:https://tools.ietf.org/html/rfc5321#section-2.3.11[
-RFC 5321 section 2.3.11] allows for the local-part (the part left of
-the '@') of an email address to be case sensitive, the domain portion is
-case insensitive according to
-link:https://tools.ietf.org/html/rfc1035#section-3.1[RFC 1035 section 3.1].
-And in practice, even the local-part is typically case insensitive also.
-
-* `commit-msg` hook: Don't add `Change-Id` line on temporary commits.
-+
-Commits created with `git commit --fixup` or `git commit --squash` are not
-intended to be pushed to Gerrit, and don't need a `Change-Id` line.
-+
-This also prevents changes from being accidentally uploaded, at least for
-projects that have the 'Require Change-Id' configuration enabled.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3444[Issue 3444]:
-download-commands plugin: Fix clone with commit-msg hook when project name
-contains '/'.
-
-* Use full ref name in `refName` attribute of `ref-updated` events.
-+
-The link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/json.html#refUpdate[
-refUpdate attribute] in `ref-updated` events did not include the full name
-of the ref in the `refName` attribute, i.e. `master` was used instead of
-`refs/heads/master`.
-+
-Support for the new format is added in
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger#GerritTrigger-Version2.15.1%28releasedSept142015%29[
-version 2.15.1 of the Jenkins Gerrit Trigger plugin].
-+
-Users who are unable to upgrade the plugin may instead change the
-trigger's branch configuration to type `Path` with a pattern like
-`refs/*/master` instead of `Plain` and `master`.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3714[Issue 3714]:
-Improve visibility of comments on dark themes.
-
-* Fix highlighting of search results and trailing whitespaces in intraline
-diff chunks.
-
-* Fix server error when listing annotated/signed tag that has no tagger info.
-
-* Don't create new account when claimed OAuth identity is unknown.
-+
-The Claimed Identity feature was enabled to support old Google OpenID accounts,
-that cannot be activated anymore. In some corner cases, when for example the URL
-is not from the production Gerrit site, for example on a staging instance, the
-OpenID identity may deviate from the original one. In case of mismatch, the lookup
-of the user for the claimed identity would fail, causing a new account to be
-created.
-
-* Suggest to upgrade installed plugins per default during site initialization
-to new Gerrit version.
-+
-The default was 'No' which resulted in some sites not upgrading core
-plugins and running the wrong versions.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3698[Issue 3698]:
-Fix creation of the administrator user on databases with pre-allocated
-auto-increment column values.
-+
-When using a database configuration where auto-increment column values are
-pre-allocated, it was possible that the 'Administrators' group was created
-with an ID other than `1`. In this case, the created admin user was not added
-to the correct group, and did not have the correct admin permissions.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3018[Issue 3018]:
-Fix query for changes using a label with a group operator.
-+
-The `group` operator was being ignored when searching for changes with labels
-because the search index does not contain group information.
-
-* Fix online reindexing of changes that don't already exist in the index.
-+
-Changes are now always reloaded from the database during online reindex.
-
-* Fix reading of plugin documentation.
-+
-Under some circumstances it was possible to fail with an IO error.
-
-== Documentation Updates
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=412[Issue 412]:
-Update documentation of `commentlink.match` regular expression to clarify
-that the expression is applied to the rendered HTML.
-
-* Remove warning about unstable change edit REST API endpoints.
-+
-These endpoints should be considered stable since version 2.11.
-
-* Document that `ldap.groupBase` and `ldap.accountBase` are repeatable.
-
-== Upgrades
-
-* Upgrade Asciidoctor to 1.5.2
-
-* Upgrade AutoValue to 1.1
-
-* Upgrade Bouncy Castle to 1.52
-
-* Upgrade CodeMirror to 5.7
-
-* Upgrade gson to 2.3.1
-
-* Upgrade guava to 19.0-RC2
-
-* Upgrade gwtorm to 1.14-20-gec13fdc
-
-* Upgrade H2 to 1.3.176
-
-* Upgrade httpcomponents to 4.4.1
-
-* Upgrade Jetty to 9.2.13.v20150730
-
-* Upgrade JGit to 4.1.1.201511131810-r
-
-* Upgrade joda-time to 2.8
-
-* Upgrade JRuby to 1.7.18
-
-* Upgrade jsch to 0.1.53
-
-* Upgrade JUnit to 4.11
-
-* Upgrade Lucene to 5.3.0
-
-* Upgrade Prolog Cafe 1.4.1
-
-* Upgrade servlet API to 8.0.24
-
-* Upgrade Truth to version 0.27
-
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md[
+Release notes for Gerrit 2.12].
diff --git a/ReleaseNotes/ReleaseNotes-2.13.1.txt b/ReleaseNotes/ReleaseNotes-2.13.1.txt
index 958e726..7b27ad3 100644
--- a/ReleaseNotes/ReleaseNotes-2.13.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.13.1.txt
@@ -1,21 +1,5 @@
 = Release notes for Gerrit 2.13.1
 
-Gerrit 2.13.1 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.13.1.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.13.1.war]
-
-== Schema Upgrade
-
-There are no schema changes from link:ReleaseNotes-2.13.html[2.13].
-
-== Bug Fixes
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4618[Issue 4618]:
-Fix internal server error after online reindexing completed.
-
-* Fix internal server error when cloning from slaves and not all refs are
-visible.
-
-* Fix JSON deserialization error causing stream event client to no longer receive
-events.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.13.md#2.13.1[
+Release notes for Gerrit 2.13.1].
diff --git a/ReleaseNotes/ReleaseNotes-2.13.2.txt b/ReleaseNotes/ReleaseNotes-2.13.2.txt
index c7be976..72bd218 100644
--- a/ReleaseNotes/ReleaseNotes-2.13.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.13.2.txt
@@ -1,46 +1,5 @@
 = Release notes for Gerrit 2.13.2
 
-Gerrit 2.13.2 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.13.2.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.13.2.war]
-
-== Schema Upgrade
-
-There are no schema changes from link:ReleaseNotes-2.13.1.html[2.13.1].
-
-== Bug Fixes
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4630[Issue 4630]:
-Fix server error when navigating up to change while 'Working' is displayed.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4631[Issue 4631]:
-Read project watches from database.
-+
-Project watches were being read from the git backend by default, but the
-migration to git is not yet completed.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4632[Issue 4632]:
-Fix server error when deleting multiple SSH keys from the Web UI.
-+
-Attempting to delete multiple keys in parallel resulted in a lock failure
-when removing the keys from the git backend.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4645[Issue 4645]:
-Fix malformed account suggestions.
-+
-If the query contained several query terms and one of the query terms was
-a substring of 'strong', the suggestion was malformed.
-
-* Hooks plugin: Fix incorrect value passed to `--change-url` parameter.
-+
-The URL was being generated using the change's Change-Id rather than the
-change number.
-
-* Check for CLA when creating project config changes from the web UI.
-+
-If contributor agreements were enabled and required for a project, and
-the user had not signed a CLA, it was still possible to upload changes
-for review on `refs/meta/config` by making changes in the project access
-editor and pressing 'Save for Review'.
-
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.13.md#2.13.2[
+Release notes for Gerrit 2.13.2].
diff --git a/ReleaseNotes/ReleaseNotes-2.13.txt b/ReleaseNotes/ReleaseNotes-2.13.txt
index 0afca1a..b3e125d 100644
--- a/ReleaseNotes/ReleaseNotes-2.13.txt
+++ b/ReleaseNotes/ReleaseNotes-2.13.txt
@@ -1,471 +1,5 @@
 = Release notes for Gerrit 2.13
 
-
-Gerrit 2.13 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.13.war[
-https://www.gerritcodereview.com/download/gerrit-2.13.war]
-
-
-== Important Notes
-
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* To use online reindexing for `changes` secondary index when upgrading
-to 2.13.x, the server must first be upgraded to 2.8 (or 2.9) and then through
-2.10, 2.11 and 2.12. Skipping a version will prevent the online reindexer from
-working.
-
-Gerrit 2.13 introduces a new secondary index for accounts, and this must be
-indexed offline before starting Gerrit:
-----
-  java -jar gerrit.war reindex --index accounts -d site_path
-----
-
-If reindexing will be done offline, you may ignore these warnings and upgrade
-directly to 2.13.x using the following command that will reindex both `changes`
-and `accounts` secondary indexes:
-----
-  java -jar gerrit.war reindex -d site_path
-----
-
-*WARNING:* The server side hooks functionality is moved to a core plugin. Sites
-that make use of server side hooks must install this plugin during site init.
-
-
-== Release Highlights
-
-* Support for Large File Storage (LFS).
-
-* Metrics interface.
-
-* Hooks plugin.
-
-* Secondary index for accounts.
-
-* File annotations (blame) in side-by-side diff.
-
-== New Features
-
-=== Large File Storage (LFS)
-
-Gerrit provides an
-link:https://gerrit-review.googlesource.com/Documentation/2.13/dev-plugins.html#lfs-extension[
-extension point] that enables development of plugins implementing the
-link:https://github.com/github/git-lfs/blob/master/docs/api/v1/http-v1-batch.md[
-LFS protocol].
-
-By setting
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#lfs.plugin[
-`lfs.plugin`] the administrator can configure the name of the plugin
-which handles LFS requests.
-
-=== Access control for git submodule subscriptions
-
-To prevent potential security breaches as described in
-link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3311[issue 3311],
-it is now only possible for a project to subscribe to a submodule if the
-submodule explicitly allows itself to be subscribed.
-
-Please see the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/user-submodules.html[
-submodules user guide] for details.
-
-Note that when upgrading from an earlier version of Gerrit, permissions for
-any existing subscriptions will be automatically added during the database
-schema migration.
-
-=== Metrics
-
-Metrics about Gerrit's internal state can be sent to external
-monitoring systems.
-
-Plugins can provide implementations of the metrics interface to
-report metrics to different monitoring systems. The following
-plugins are available:
-
-* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-jmx[
-JMX]
-
-* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-graphite[
-Graphite]
-
-* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-elasticsearch[
-Elasticsearch]
-
-Plugins can also provide their own metrics.
-
-See the link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/metrics.html[
-metrics documentation] for further details.
-
-=== Hooks
-
-Server side hooks are moved to the
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/hooks[
-hooks plugin]. Sites that make use of server side hooks should install this
-plugin. After installing the plugin, no additional configuration is needed.
-The plugin uses the same configuration settings in `gerrit.config`.
-
-=== Secondary Index
-
-* The secondary index now supports indexing of accounts.
-+
-The link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/pgm-reindex.html[
-reindex program] by default reindexes all changes and accounts. A new
-option allows to explicitly specify whether to reindex changes or accounts.
-+
-The `suggest.fullTextSearch`, `suggest.fullTextSearchMaxMatches` and
-`suggest.fullTextSearchRefresh` configuration options are removed. Full text
-search is supported by default with the account secondary index.
-
-* New ssh command to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/cmd-index-changes.html[
-reindex changes].
-
-
-=== UI
-
-* The UI can now be loaded in an iFrame by enabling
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#gerrit.canLoadInIFrame[
-gerrit.canLoadInIFrame] in the site configuration.
-
-==== Change Screen
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=106[Issue 106]:
-Allow to select merge commit's parent for diff base in change screen.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3035[Issue 3035]:
-Allow to remove specific votes from a change, while leaving the reviewer on the
-change.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3487[Issue 3487]:
-Use 'Ctrl-Alt-e' instead of 'e' to open edit mode.
-
-==== Diff Screens
-
-* Add all syntax highlighting available in CodeMirror.
-
-* Improve search experience in diff screen
-+
-Ctrl-F, Ctrl-G and Shift-Ctrl-G now bind to the search dialog box provided by
-CodeMirror's search add-on. Enter and Shift-Enter navigate among the search
-results from the CodeMirror search, just like they do in a normal browser
-search. Esc now clears the search result.
-+
-If the user sets `Render` to `Slow` in the diff preferences and the file is less
-than 4000 lines (huge), then Ctrl-F, Ctrl-G and Shift-Ctrl-G fall back to the
-browser search.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=2968[Issue 2968]:
-Allow to go back to change list by keyboard shortcut from diff screens.
-
-==== Side-By-Side Diff Screen
-
-* Blame annotations
-+
-By enabling
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#change.allowBlame[
-`change.allowBlame`], blame annotations can be shown in the side-by-side diff
-screen gutter. Clicking the annotation opens the relevant change.
-
-==== User Preferences
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=989[Issue 989]:
-New option to control email notifications.
-+
-Users can now choose between 'Enabled', 'Disabled' and 'CC Me on Comments I Write'.
-
-* New option to control adding 'Signed-off-by' footer in commit message of new changes
-created online.
-
-* New option to control auto-indent width in inline editor.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=890[Issue 890]:
-New diff option to control whether to skip unchanged files when navigating to
-the previous or the next file.
-
-=== Changes
-
-In order to avoid potentially confusing behavior, when submitting changes in a
-batch, submit type rules may not be used to mix submit types on a single branch,
-and trying to submit such a batch will fail.
-
-=== REST API
-
-==== Accounts
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3766[Issue 3766]:
-Allow users with the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/access-control.html#capability_modifyAccount[
-'ModifyAccount' capability] to get the preferences for other users via the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-user-preferences[
-Get User Preferences] endpoint.
-
-* Rename 'Suggest Account' to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#query-account[
-'Query Account'] and add support for arbitrary account queries.
-+
-The `_more_accounts` flag is set on the last result when there are more results
-than the limit. The `DETAILS` and `ALL_EMAILS` options may be set to control
-whether the results should include details (full name, email, username, avatars)
-and all emails, respectively.
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-watched-projects[
-Get Watched Projects].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#set-watched-projects[
-Set Watched Projects].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#delete-watched-projects[
-Delete Watched Projects].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-stars[
-Get Star Labels from Change].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#set-stars[
-Update Star Labels on Change].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-oauth-token[
-Get OAuth Access Token].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#list-contributor-agreements[
-List Contributor Agreements].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#sign-contributor-agreement[
-Sign Contributor Agreement].
-
-==== Changes
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3579[Issue 3579]:
-Append submitted info to ChangeInfo.
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-changes.html#move-change[
-Move Change].
-
-==== Groups
-
-* Add `-s` as an alias for `--suggest` on the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-groups.html#suggest-group[
-Suggest Group] endpoint.
-
-==== Projects
-
-* Add `async` option to the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#run-gc[
-Run GC] endpoint to allow garbage collection to run asynchronously.
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#get-access[
-List Access Rights].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#set-access[
-Add, Update and Delete Access Rights].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#create-tag[
-Create Tag].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#get-mergeable-info[
-Get Mergeable Information].
-
-=== Plugins
-
-* Secure settings
-+
-Plugins may now store secure settings in `etc/$PLUGIN.secure.config` where they
-will be decoded by the Secure Store implementation.
-
-* Exported dependencies
-+
-Gson is now an exported dependency. Plugins no longer need to explicitly add
-a dependency on it.
-
-=== Misc
-
-* New project option to reject implicit merge commits.
-+
-The 'Reject Implicit Merges' option can be enabled to prevent non-merge commits
-from implicitly bringing unwanted changes into a branch. This can happen for
-example when a commit is made based on one branch but is mistakenly pushed to
-another, for example based on `refs/heads/master` but pushed to `refs/for/stable`.
-
-* New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/access-control.html#category_add_patch_set[
-Add Patch Set capability] to control who is allowed to upload a new patch
-set to an existing change.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4015[Issue 4015]:
-Allow setting a
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/user-upload.html#message[
-comment message] when uploading a change.
-
-* Allow to specify
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/user-upload.html#notify[
-who should be notified by email] when uploading a change.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3220[Issue 3220]:
-Append approval info to every comment-added stream event and hook.
-
-* The `administrateServer` capability can be assigned to groups by setting
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#capability.administrateServer[
-capability.administrateServer] in the site configuration.
-+
-Configuring this option can be a useful fail-safe to recover a server in the
-event an administrator removed all groups from the `administrateServer`
-capability, or to ensure that specific groups always have administration
-capabilities.
-
-* New configuration options to configure JGit repository cache parameters.
-+
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#core.repositoryCacheCleanupDelay[
-core.repositoryCacheCleanupDelay] and
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#core.repositoryCacheExpireAfter[
-core.repositoryCacheExpireAfter] can be configured.
-
-* Accept `-b` as an alias of `--batch` in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/pgm-init.html[
-init program].
-
-
-== Bug Fixes
-
-* Don't add the same SSH key multiple times.
-+
-If an already existing SSH key was added, a duplicate entry was added to the
-list of user's SSH keys.
-
-* Respect the 'Require a valid contributor agreement to upload' setting
-when creating changes via the UI.
-+
-If a user had not signed a CLA, it was still possible for them to create a new
-change with the 'Revert' or 'Cherry Pick' button.
-
-* Make Lucene index more stable when being interrupted.
-
-* Don't show the `start` and `idle` columns in the `show-connections`
-output when the ssh backend is NIO2.
-+
-The NIO2 backend doesn't provide the start and idle times, and the
-values being displayed were just dummy values. Now these values are
-only displayed for the MINA backend.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4150[Issue 4150]:
-Deleting a draft inline comment no longer causes the change's `Updated` field to
-be bumped.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4099[Issue 4099]:
-Fix SubmitWholeTopic does not update subscriptions.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3603[Issue 3603]:
-Fix editing a submodule via inline edit.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4069[Issue 4069]:
-Fix highlights in scrollbar overview ruler not moved when extending the
-displayed area.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3446[Issue 3446]:
-Respect the `Skip Deleted` diff preference.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3445[Issue 3445]:
-Respect the `Skip Uncommented` diff preference.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4051[Issue 4051]:
-Fix empty `From` email header.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3423[Issue 3423]:
-Fix intraline diff for added spaces.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=1867[Issue 1867]:
-Remove `no changes made` error case when the only difference between a new
-commit and the previous patch set of the change is the committer.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3831[Issue 3831]:
-Prevent creating groups with the same name as a system group.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3754[Issue 3754]:
-Fix `View All Accounts` permission to allow accounts REST endpoint to access
-email info.
-
-* Make `gitweb.type` default to `disabled` when not explicitly set.
-+
-Previously the behavior was not documented and it would default to type
-`gitweb`. In cases where there was no gitweb config at all, this would
-result in broken links due to `null` being used as the URL.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4488[Issue 4488]:
-Improve error message when `Change-Id` line is missing in commit message.
-+
-The error message now includes the sha1 of the commit, so that it is
-easier to track down which commit failed validation when multiple commits
-are pushed at the same time.
-
-* Don't check mergeability of draft changes.
-+
-Draft changes can be deleted but not abandoned so there is no way for
-an administrator to get rid of the them on behalf of the users. This can
-become a problem when there many draft changes because the mergeability
-check can be costly.
-+
-The mergeability check is no longer done for draft changes, but will be
-done when the draft change is published.
-
-* Fix internal server error when plugin-provided file history weblink
-is null.
-+
-It is valid for a plugin to provide a null weblink, but doing so resulted
-in an internal server error.
-
-== Dependency updates
-
-* Add dependency on blame-cache 0.1-9
-
-* Add dependency on guava-retrying 2.0.0
-
-* Add dependency on jsr305 3.0.1
-
-* Add dependency on metrics-core 3.1.2
-
-* Upgrade auto-value to 1.3-rc1
-
-* Upgrade commons-net to 3.5
-
-* Upgrade CodeMirror to 5.17.0
-
-* Upgrade Guava to 19.0
-
-* Upgrade Gson to 2.7
-
-* Upgrade Guice to 4.1.0
-
-* Upgrade gwtjsonrpc to 1.9
-
-* Upgrade gwtorm to 1.15
-
-* Upgrade javassist to 3.20.0-GA
-
-* Upgrade Jetty to 9.2.14.v20151106
-
-* Upgrade JGit to 4.5.0.201609210915-r
-
-* Upgrade joda-convert to 1.8.1
-
-* Upgrade joda-time to 2.9.4
-
-* Upgrade Lucene to 5.5.0
-
-* Upgrade mina to 2.0.10
-
-* Upgrade sshd-core to 1.2.0
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.13.md[
+Release notes for Gerrit 2.13].
diff --git a/ReleaseNotes/index.txt b/ReleaseNotes/index.txt
index 945f09f..9c28697 100644
--- a/ReleaseNotes/index.txt
+++ b/ReleaseNotes/index.txt
@@ -2,18 +2,18 @@
 
 [[s2_13]]
 == Version 2.13.x
-* link:ReleaseNotes-2.13.2.html[2.13.2]
-* link:ReleaseNotes-2.13.1.html[2.13.1]
-* link:ReleaseNotes-2.13.html[2.13]
+* link:https://www.gerritcodereview.com/releases/2.13.md#2.13.2[2.13.2]
+* link:https://www.gerritcodereview.com/releases/2.13.md#2.13.1[2.13.1]
+* link:https://www.gerritcodereview.com/releases/2.13.md[2.13]
 
 [[s2_12]]
 == Version 2.12.x
-* link:ReleaseNotes-2.12.5.html[2.12.5]
-* link:ReleaseNotes-2.12.4.html[2.12.4]
-* link:ReleaseNotes-2.12.3.html[2.12.3]
-* link:ReleaseNotes-2.12.2.html[2.12.2]
-* link:ReleaseNotes-2.12.1.html[2.12.1]
-* link:ReleaseNotes-2.12.html[2.12]
+* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.5[2.12.5]
+* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.4[2.12.4]
+* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.3[2.12.3]
+* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.2[2.12.2]
+* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.1[2.12.1]
+* link:https://www.gerritcodereview.com/releases/2.12.md[2.12]
 
 [[s2_11]]
 == Version 2.11.x
diff --git a/WORKSPACE b/WORKSPACE
index d465b37..a3db958 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,3 +1,5 @@
+workspace(name="gerrit")
+
 ANTLR_VERS = '3.5.2'
 
 maven_jar(
@@ -24,24 +26,30 @@
   sha1 = '83cd2cd674a217ade95a4bb83a8a14f351f48bd0',
 )
 
-GUICE_VERS = '4.0'
+GUICE_VERS = '4.1.0'
 
 maven_jar(
   name = 'guice_library',
   artifact = 'com.google.inject:guice:' + GUICE_VERS,
-  sha1 = '0f990a43d3725781b6db7cd0acf0a8b62dfd1649',
+  sha1 = 'eeb69005da379a10071aa4948c48d89250febb07',
 )
 
 maven_jar(
   name = 'guice_assistedinject',
   artifact = 'com.google.inject.extensions:guice-assistedinject:' + GUICE_VERS,
-  sha1 = '8fa6431da1a2187817e3e52e967535899e2e46ca',
+  sha1 = 'af799dd7e23e6fe8c988da12314582072b07edcb',
 )
 
 maven_jar(
   name = 'guice_servlet',
   artifact = 'com.google.inject.extensions:guice-servlet:' + GUICE_VERS,
-  sha1 = '4503da866f4c402b5090579b40c1c4aaefabb164',
+  sha1 = '90ac2db772d9b85e2b05417b74f7464bcc061dcb',
+)
+
+maven_jar(
+  name = 'multibindings',
+  artifact = 'com.google.inject.extensions:guice-multibindings:' + GUICE_VERS,
+  sha1 = '3b27257997ac51b0f8d19676f1ea170427e86d51',
 )
 
 maven_jar(
@@ -62,18 +70,18 @@
   sha1 = '5d9e2e895e3111622720157d0aa540066d5fce3a',
 )
 
-GWT_VERS = '2.7.0'
+GWT_VERS = '2.8.0'
 
 maven_jar(
   name = 'user',
   artifact = 'com.google.gwt:gwt-user:' + GWT_VERS,
-  sha1 = 'bdc7af42581745d3d79c2efe0b514f432b998a5b',
+  sha1 = '518579870499e15531f454f35dca0772d7fa31f7',
 )
 
 maven_jar(
   name = 'dev',
   artifact = 'com.google.gwt:gwt-dev:' + GWT_VERS,
-  sha1 = 'c2c3dd5baf648a0bb199047a818be5e560f48982',
+  sha1 = 'f160a61272c5ebe805cd2d3d3256ed3ecf14893f',
 )
 
 maven_jar(
@@ -82,28 +90,68 @@
   sha1 = 'b6bd7f9d78f6fdaa3c37dae18a4bd298915f328e',
 )
 
-JGIT_VERS = '4.4.1.201607150455-r.105-g81ba2be'
+maven_jar(
+  name = 'jsinterop_annotations',
+  artifact = 'com.google.jsinterop:jsinterop-annotations:1.0.0',
+  sha1 = '23c3a3c060ffe4817e67673cc8294e154b0a4a95',
+)
+
+maven_jar(
+  name = 'ant',
+  artifact = 'ant:ant:1.6.5',
+  sha1 = '7d18faf23df1a5c3a43613952e0e8a182664564b',
+)
+
+maven_jar(
+  name = 'colt',
+  artifact = 'colt:colt:1.2.0',
+  sha1 = '0abc984f3adc760684d49e0f11ddf167ba516d4f',
+)
+
+maven_jar(
+  name = 'tapestry',
+  artifact = 'tapestry:tapestry:4.0.2',
+  sha1 = 'e855a807425d522e958cbce8697f21e9d679b1f7',
+)
+
+maven_jar(
+  name = 'w3c_css_sac',
+  artifact = 'org.w3c.css:sac:1.3',
+  sha1 = 'cdb2dcb4e22b83d6b32b93095f644c3462739e82',
+)
+
+http_jar(
+  name = "javax_validation_src",
+  url = "http://repo1.maven.org/maven2/javax/validation/validation-api/1.0.0.GA/validation-api-1.0.0.GA-sources.jar",
+  sha256 = 'a394d52a9b7fe2bb14f0718d2b3c8308ffe8f37e911956012398d55c9f9f9b54',
+)
+
+http_jar(
+  name = "jsinterop_annotations_src",
+  url = "http://central.maven.org/maven2/com/google/jsinterop/jsinterop-annotations/1.0.0/jsinterop-annotations-1.0.0-sources.jar",
+  sha256 = '80d63c117736ae2fb9837b7a39576f3f0c5bd19cd75127886550c77b4c478f87',
+)
+
+load('//lib/jgit:jgit.bzl', 'JGIT_VERS')
 
 maven_jar(
   name = 'jgit',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
   artifact = 'org.eclipse.jgit:org.eclipse.jgit:' + JGIT_VERS,
-  sha1 = 'c07c9c66da7983095a40945c0bfab211a473c4c5',
+  sha1 = '3e3d0b73dcf4ad649f37758ea8502d92f3d299de',
 )
 
 maven_jar(
   name = 'jgit_servlet',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
   artifact = 'org.eclipse.jgit:org.eclipse.jgit.http.server:' + JGIT_VERS,
-  sha1 = 'bb01841b74a48abe506c2e44f238e107188e6c8f',
+  sha1 = '6e36638888918d9941dddec7e2abe1f162cc74d9',
 )
 
 # TODO(davido): Remove this hack when maven_jar supports pulling sources
 # https://github.com/bazelbuild/bazel/issues/308
 http_file(
   name = 'jgit_src',
-  sha256 = '881906cb1e6743cb78df6dd3788cab7e974308fbb98cab4915e6591a62aa9374',
-  url = 'http://gerrit-maven.storage.googleapis.com/org/eclipse/jgit/org.eclipse.jgit/' +
+  sha256 = '426bf32d097a846a247d5fb1d258fcde1707dec3362b8a62c68785b953c2ae65',
+  url = 'http://repo1.maven.org/maven2/org/eclipse/jgit/org.eclipse.jgit/' +
       '%s/org.eclipse.jgit-%s-sources.jar' % (JGIT_VERS, JGIT_VERS),
 )
 
@@ -115,46 +163,44 @@
 
 maven_jar(
   name = 'jgit_archive',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
   artifact = 'org.eclipse.jgit:org.eclipse.jgit.archive:' + JGIT_VERS,
-  sha1 = 'fc3bc40e070c54198a046fcd3a1f7cac47163961',
+  sha1 = '2db2e7666672a31fa41b7e1dadcba51df6d30954',
 )
 
 maven_jar(
   name = 'jgit_junit',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
   artifact = 'org.eclipse.jgit:org.eclipse.jgit.junit:' + JGIT_VERS,
-  sha1 = 'b4565ee84a6e1d0952010282b9fcf705ac6171a7',
+  sha1 = 'e8fb1d81f588c3174a9730bdecdbde9faa04140a',
 )
 
 maven_jar(
   name = 'gwtjsonrpc',
-  artifact = 'com.google.gerrit:gwtjsonrpc:1.8',
-  sha1 = 'c264bf2f543cffddceada5cdf031eea06dbd44a0',
+  artifact = 'com.google.gerrit:gwtjsonrpc:1.11',
+  sha1 = '0990e7eec9eec3a15661edcf9232acbac4aeacec',
 )
 
 http_jar(
   name = 'gwtjsonrpc_src',
-  sha256 = '2ef86396861a7c555c404b5a20a72dc6599b541ce2d1370a62f6470eefe7142d',
-  url = 'http://repo.maven.apache.org/maven2/com/google/gerrit/gwtjsonrpc/1.8/gwtjsonrpc-1.8-sources.jar',
+  sha256 = 'fc503488872c022073e244015fcb6806a64b65afe546bdac2db167a3875fb418',
+  url = 'http://repo.maven.apache.org/maven2/com/google/gerrit/gwtjsonrpc/1.11/gwtjsonrpc-1.11-sources.jar',
 )
 
 maven_jar(
   name = 'gson',
-  artifact = 'com.google.code.gson:gson:2.6.2',
-  sha1 = 'f1bc476cc167b18e66c297df599b2377131a8947',
+  artifact = 'com.google.code.gson:gson:2.7',
+  sha1 = '751f548c85fa49f330cecbb1875893f971b33c4e',
 )
 
 maven_jar(
   name = 'gwtorm_client',
-  artifact = 'com.google.gerrit:gwtorm:1.15',
-  sha1 = '26a2459f543ed78977535f92e379dc0d6cdde8bb',
+  artifact = 'com.google.gerrit:gwtorm:1.16',
+  sha1 = '3e41b6d7bb352fa0539ce23b9bce97cf8c26c3bf',
 )
 
 http_jar(
   name = 'gwtorm_client_src',
-  sha256 = 'e0cf9382ed8c3cd1f0884ab77dabe634a04546676c4960d8b4c4b64a20132ef6',
-  url = 'http://repo.maven.apache.org/maven2/com/google/gerrit/gwtorm/1.15/gwtorm-1.15-sources.jar',
+  sha256 = 'd3e482c9ac1f828aa853debe6545c16503fbbde3bda94b18f652d9830b7f84b1',
+  url = 'http://repo.maven.apache.org/maven2/com/google/gerrit/gwtorm/1.16/gwtorm-1.16-sources.jar',
 )
 
 maven_jar(
@@ -165,20 +211,22 @@
 
 maven_jar(
   name = 'joda_time',
-  artifact = 'joda-time:joda-time:2.8',
-  sha1 = '9f2785d7184b97d005a44241ccaf980f43b9ccdb',
+  artifact = 'joda-time:joda-time:2.9.4',
+  sha1 = '1c295b462f16702ebe720bbb08f62e1ba80da41b',
 )
 
 maven_jar(
   name = 'joda_convert',
-  artifact = 'org.joda:joda-convert:1.2',
-  sha1 = '35ec554f0cd00c956cc69051514d9488b1374dec',
+  artifact = 'org.joda:joda-convert:1.8.1',
+  sha1 = '675642ac208e0b741bc9118dcbcae44c271b992a',
 )
 
+load('//lib:guava.bzl', 'GUAVA_VERSION', 'GUAVA_BIN_SHA1')
+
 maven_jar(
   name = 'guava',
-  artifact = 'com.google.guava:guava:19.0',
-  sha1 = '6ce200f6b23222af3d8abb6b6459e6c44f4bb0e9',
+  artifact = 'com.google.guava:guava:' + GUAVA_VERSION,
+  sha1 = GUAVA_BIN_SHA1,
 )
 
 maven_jar(
@@ -189,8 +237,8 @@
 
 maven_jar(
   name = 'jsch',
-  artifact = 'com.jcraft:jsch:0.1.53',
-  sha1 = '658b682d5c817b27ae795637dfec047c63d29935',
+  artifact = 'com.jcraft:jsch:0.1.54',
+  sha1 = 'da3584329a263616e277e15462b387addd1b208d',
 )
 
 maven_jar(
@@ -263,8 +311,8 @@
 
 maven_jar(
   name = 'commons_compress',
-  artifact = 'org.apache.commons:commons-compress:1.7',
-  sha1 = 'ab365c96ee9bc88adcc6fa40d185c8e15a31410d',
+  artifact = 'org.apache.commons:commons-compress:1.12',
+  sha1 = '84caa68576e345eb5e7ae61a0e5a9229eb100d7b',
 )
 
 maven_jar(
@@ -274,6 +322,12 @@
 )
 
 maven_jar(
+  name = 'commons_lang3',
+  artifact = 'org.apache.commons:commons-lang3:3.3.2',
+  sha1 = '90a3822c38ec8c996e84c16a3477ef632cbc87a3',
+)
+
+maven_jar(
   name = 'commons_dbcp',
   artifact = 'commons-dbcp:commons-dbcp:1.4',
   sha1 = '30be73c965cc990b153a100aaaaafcf239f82d39',
@@ -287,8 +341,8 @@
 
 maven_jar(
   name = 'commons_net',
-  artifact = 'commons-net:commons-net:2.2',
-  sha1 = '07993c12f63c78378f8c90de4bc2ee62daa7ca3a',
+  artifact = 'commons-net:commons-net:3.5',
+  sha1 = '342fc284019f590e1308056990fdb24a08f06318',
 )
 
 maven_jar(
@@ -327,42 +381,42 @@
   sha1 = '2e35862b0435c1b027a21f3d6eecbe50e6e08d54',
 )
 
-OW2_VERS = '5.0.3'
+OW2_VERS = '5.1'
 
 maven_jar(
   name = 'ow2_asm',
   artifact = 'org.ow2.asm:asm:' + OW2_VERS,
-  sha1 = 'dcc2193db20e19e1feca8b1240dbbc4e190824fa',
+  sha1 = '5ef31c4fe953b1fd00b8a88fa1d6820e8785bb45',
 )
 
 maven_jar(
   name = 'ow2_asm_analysis',
   artifact = 'org.ow2.asm:asm-analysis:' + OW2_VERS,
-  sha1 = 'c7126aded0e8e13fed5f913559a0dd7b770a10f3',
+  sha1 = '6d1bf8989fc7901f868bee3863c44f21aa63d110',
 )
 
 maven_jar(
   name = 'ow2_asm_commons',
   artifact = 'org.ow2.asm:asm-commons:' + OW2_VERS,
-  sha1 = 'a7111830132c7f87d08fe48cb0ca07630f8cb91c',
+  sha1 = '25d8a575034dd9cfcb375a39b5334f0ba9c8474e',
 )
 
 maven_jar(
   name = 'ow2_asm_tree',
   artifact = 'org.ow2.asm:asm-tree:' + OW2_VERS,
-  sha1 = '287749b48ba7162fb67c93a026d690b29f410bed',
+  sha1 = '87b38c12a0ea645791ead9d3e74ae5268d1d6c34',
 )
 
 maven_jar(
   name = 'ow2_asm_util',
   artifact = 'org.ow2.asm:asm-util:' + OW2_VERS,
-  sha1 = '1512e5571325854b05fb1efce1db75fcced54389',
+  sha1 = 'b60e33a6bd0d71831e0c249816d01e6c1dd90a47',
 )
 
 maven_jar(
   name = 'auto_value',
-  artifact = 'com.google.auto.value:auto-value:1.2',
-  sha1 = '6873fed014fe1de1051aae2af68ba266d2934471',
+  artifact = 'com.google.auto.value:auto-value:1.4-rc1',
+  sha1 = '9347939002003a7a3c3af48271fc2c18734528a4',
 )
 
 maven_jar(
@@ -371,36 +425,85 @@
   sha1 = '18a9a2ce6abf32ea1b5fd31dae5210ad93f4e5e3',
 )
 
-LUCENE_VERS = '5.4.1'
+LUCENE_VERS = '5.5.2'
 
 maven_jar(
   name = 'lucene_core',
   artifact = 'org.apache.lucene:lucene-core:' + LUCENE_VERS,
-  sha1 = 'c52b2088e2c30dfd95fd296ab6fb9cf8de9855ab',
+  sha1 = 'de5e5c3161ea01e89f2a09a14391f9b7ed66cdbb',
 )
 
 maven_jar(
   name = 'lucene_analyzers_common',
   artifact = 'org.apache.lucene:lucene-analyzers-common:' + LUCENE_VERS,
-  sha1 = 'c2aa2c4e00eb9cdeb5ac00dc0495e70c441f681e',
+  sha1 = 'f0bc3114a6b43f8e64a33c471d5b9e8ddc51564d',
+)
+
+maven_jar(
+  name = 'lucene_codecs',
+  artifact = 'org.apache.lucene:lucene-codecs:' + LUCENE_VERS,
+  sha1 = 'e01fe463d9490bb1b4a6a168e771f7b7255a50b1',
 )
 
 maven_jar(
   name = 'backward_codecs',
   artifact = 'org.apache.lucene:lucene-backward-codecs:' + LUCENE_VERS,
-  sha1 = '5273da96380dfab302ad06c27fe58100db4c4e2f',
+  sha1 = 'c5cfcd7a8cf48a0144b61fb991c8e50a0bf868d5',
 )
 
 maven_jar(
   name = 'lucene_misc',
   artifact = 'org.apache.lucene:lucene-misc:' + LUCENE_VERS,
-  sha1 = '95f433b9d7dd470cc0aa5076e0f233907745674b',
+  sha1 = '37bbe5a2fb429499dfbe75d750d1778881fff45d',
 )
 
 maven_jar(
   name = 'lucene_queryparser',
   artifact = 'org.apache.lucene:lucene-queryparser:' + LUCENE_VERS,
-  sha1 = 'dccd5279bfa656dec21af444a7a66820eb1cd618',
+  sha1 = '8ac921563e744463605284c6d9d2d95e1be5b87c',
+)
+
+
+maven_jar(
+  name = 'lucene_highlighter',
+  artifact = 'org.apache.lucene:lucene-highlighter:' + LUCENE_VERS,
+  sha1 = 'd127ac514e9df965ab0b57d92bbe0c68d3d145b8',
+)
+
+maven_jar(
+  name = 'lucene_join',
+  artifact = 'org.apache.lucene:lucene-join:'+ LUCENE_VERS,
+  sha1 = 'dac1b322508f3f2696ecc49a97311d34d8382054',
+)
+
+maven_jar(
+  name = 'lucene_memory',
+  artifact = 'org.apache.lucene:lucene-memory:' + LUCENE_VERS,
+  sha1 = '7409db9863d8fbc265c27793c6cc7511304182c2',
+)
+
+maven_jar(
+  name = 'lucene_sandbox',
+  artifact = 'org.apache.lucene:lucene-sandbox:' + LUCENE_VERS,
+  sha1 = '30a91f120706ba66732d5a974b56c6971b3c8a16',
+)
+
+maven_jar(
+  name = 'lucene_spatial',
+  artifact = 'org.apache.lucene:lucene-spatial:' + LUCENE_VERS,
+  sha1 = '8ed7a9a43d78222038573dd1c295a61f3c0bb0db',
+)
+
+maven_jar(
+  name = 'lucene_suggest',
+  artifact = 'org.apache.lucene:lucene-suggest:' + LUCENE_VERS,
+  sha1 = 'e8316b37dddcf2092a54dab2ce6aad0d5ad78585',
+)
+
+maven_jar(
+  name = 'lucene_queries',
+  artifact = 'org.apache.lucene:lucene-queries:' + LUCENE_VERS,
+  sha1 = '692f1ad887cf4e006a23f45019e6de30f3312d3f',
 )
 
 maven_jar(
@@ -409,34 +512,34 @@
   sha1 = '0c9cfae15c74f62491d4f28def0dff1dabe52a47',
 )
 
-PROLOG_VERS = '1.4.1'
+PROLOG_VERS = '1.4.2'
 
 maven_jar(
   name = 'prolog_runtime',
   repository = 'http://gerrit-maven.storage.googleapis.com/',
   artifact = 'com.googlecode.prolog-cafe:prolog-runtime:' + PROLOG_VERS,
-  sha1 = 'c5d9f92e49c485969dcd424dfc0c08125b5f8246',
+  sha1 = '4421b4806b6e3a318680f6ab1d57569e857169c6',
 )
 
 maven_jar(
   name = 'prolog_compiler',
   repository = 'http://gerrit-maven.storage.googleapis.com/',
   artifact = 'com.googlecode.prolog-cafe:prolog-compiler:' + PROLOG_VERS,
-  sha1 = 'ac24044c6ec166fdcb352b78b80d187ead3eff41',
+  sha1 = '7e5a7ca5efe7db7f69e015cf492f8f04665244d8',
 )
 
 maven_jar(
   name = 'prolog_io',
   repository = 'http://gerrit-maven.storage.googleapis.com/',
   artifact = 'com.googlecode.prolog-cafe:prolog-io:' + PROLOG_VERS,
-  sha1 = 'b072426a4b1b8af5e914026d298ee0358a8bb5aa',
+  sha1 = 'd177f6211d1013e0f31a507127f5c87a7f6941f3',
 )
 
 maven_jar(
   name = 'cafeteria',
   repository = 'http://gerrit-maven.storage.googleapis.com/',
   artifact = 'com.googlecode.prolog-cafe:prolog-cafeteria:' + PROLOG_VERS,
-  sha1 = '8cbc3b0c19e7167c42d3f11667b21cb21ddec641',
+  sha1 = '11f396cb2588b65e6a78070488aaa58d12bf000e',
 )
 
 maven_jar(
@@ -447,8 +550,8 @@
 
 maven_jar(
   name = 'jsr305',
-  artifact = 'com.google.code.findbugs:jsr305:2.0.2',
-  sha1 = '516c03b21d50a644d538de0f0369c620989cd8f0',
+  artifact = 'com.google.code.findbugs:jsr305:3.0.1',
+  sha1 = 'f7be08ec23c21485b9b5a1cf1654c2ec8c58168d',
 )
 
 maven_jar(
@@ -458,6 +561,19 @@
   sha1 = '51d35e6f8bbc2412265066cea9653dd758c95826',
 )
 
+# Keep this version of Soy synchronized with the version used in Gitiles.
+maven_jar(
+  name = 'soy',
+  artifact = 'com.google.template:soy:2016-08-09',
+  sha1 = '43d33651e95480d515fe26c10a662faafe3ad1e4',
+)
+
+maven_jar(
+  name = 'icu4j',
+  artifact = 'com.ibm.icu:icu4j:57.1',
+  sha1 = '198ea005f41219f038f4291f0b0e9f3259730e92',
+)
+
 maven_jar(
   name = 'dropwizard_core',
   artifact = 'io.dropwizard.metrics:metrics-core:3.1.2',
@@ -466,24 +582,24 @@
 
 # This version must match the version that also appears in
 # gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
-BC_VERS = '1.52'
+BC_VERS = '1.55'
 
 maven_jar(
   name = 'bcprov',
   artifact = 'org.bouncycastle:bcprov-jdk15on:' + BC_VERS,
-  sha1 = '88a941faf9819d371e3174b5ed56a3f3f7d73269',
+  sha1 = '935f2e57a00ec2c489cbd2ad830d4a399708f979',
 )
 
 maven_jar(
   name = 'bcpg',
   artifact = 'org.bouncycastle:bcpg-jdk15on:' + BC_VERS,
-  sha1 = 'ff4665a4b5633ff6894209d5dd10b7e612291858',
+  sha1 = '54ce841795ecdf10f24e50c48d4fdec59c691699',
 )
 
 maven_jar(
   name = 'bcpkix',
   artifact = 'org.bouncycastle:bcpkix-jdk15on:' + BC_VERS,
-  sha1 = 'b8ffac2bbc6626f86909589c8cc63637cc936504',
+  sha1 = '6392d8cba22b722c6570d660ca0b3921ff1bae4f',
 )
 
 maven_jar(
@@ -534,8 +650,8 @@
 
 maven_jar(
   name = 'jimfs',
-  artifact = 'com.google.jimfs:jimfs:1.0',
-  sha1 = 'edd65a2b792755f58f11134e76485a928aab4c97',
+  artifact = 'com.google.jimfs:jimfs:1.1',
+  sha1 = '8fbd0579dc68aba6186935cc1bee21d2f3e7ec1c',
 )
 
 maven_jar(
@@ -552,64 +668,64 @@
 
 maven_jar(
   name = 'truth',
-  artifact = 'com.google.truth:truth:0.28',
-  sha1 = '0a388c7877c845ff4b8e19689dda5ac9d34622c4',
+  artifact = 'com.google.truth:truth:0.30',
+  sha1 = '9d591b5a66eda81f0b88cf1c748ab8853d99b18b',
 )
 
 maven_jar(
   name = 'easymock',
-  artifact = 'org.easymock:easymock:3.4', # When bumping the version
-  sha1 = '9fdeea183a399f25c2469497612cad131e920fa3',
+  artifact = 'org.easymock:easymock:3.1', # When bumping the version
+  sha1 = '3e127311a86fc2e8f550ef8ee4abe094bbcf7e7e',
 )
 
 maven_jar(
-  name = 'cglib_2_2',
-  artifact = 'cglib:cglib-nodep:2.2.2',
-  sha1 = '00d456bb230c70c0b95c76fb28e429d42f275941',
+  name = 'cglib_3_2',
+  artifact = 'cglib:cglib-nodep:3.2.0',
+  sha1 = 'cf1ca207c15b04ace918270b6cb3f5601160cdfd',
 )
 
 maven_jar(
   name = 'objenesis',
-  artifact = 'org.objenesis:objenesis:2.2',
-  sha1 = '3fb533efdaa50a768c394aa4624144cf8df17845',
+  artifact = 'org.objenesis:objenesis:1.3',
+  sha1 = 'dc13ae4faca6df981fc7aeb5a522d9db446d5d50',
 )
 
-POWERM_VERS = '1.6.4'
+POWERM_VERS = '1.6.1'
 
 maven_jar(
   name = 'powermock_module_junit4',
   artifact = 'org.powermock:powermock-module-junit4:' + POWERM_VERS,
-  sha1 = '8692eb1d9bb8eb1310ffe8a20c2da7ee6d1b5994',
+  sha1 = 'ea8530b2848542624f110a393513af397b37b9cf',
 )
 
 maven_jar(
   name = 'powermock_module_junit4_common',
   artifact = 'org.powermock:powermock-module-junit4-common:' + POWERM_VERS,
-  sha1 = 'b0b578da443794ceb8224bd5f5f852aaf40f1b81',
+  sha1 = '7222ced54dabc310895d02e45c5428ca05193cda',
 )
 
 maven_jar(
   name = 'powermock_reflect',
   artifact = 'org.powermock:powermock-reflect:' + POWERM_VERS,
-  sha1 = '5532f4e7c42db4bca4778bc9f1afcd4b0ee0b893',
+  sha1 = '97d25eda8275c11161bcddda6ef8beabd534c878',
 )
 
 maven_jar(
   name = 'powermock_api_easymock',
   artifact = 'org.powermock:powermock-api-easymock:' + POWERM_VERS,
-  sha1 = '5c385a0d8c13f84b731b75c6e90319c532f80b45',
+  sha1 = 'aa740ecf89a2f64d410b3d93ef8cd6833009ef00',
 )
 
 maven_jar(
   name = 'powermock_api_support',
   artifact = 'org.powermock:powermock-api-support:' + POWERM_VERS,
-  sha1 = '314daafb761541293595630e10a3699ebc07881d',
+  sha1 = '592ee6d929c324109d3469501222e0c76ccf0869',
 )
 
 maven_jar(
   name = 'powermock_core',
   artifact = 'org.powermock:powermock-core:' + POWERM_VERS,
-  sha1 = '85fb32e9ccba748d569fc36aef92e0b9e7f40b87',
+  sha1 = '5afc1efce8d44ed76b30af939657bd598e45d962',
 )
 
 maven_jar(
@@ -624,60 +740,60 @@
   sha1 = 'df4b50061e8e4c348ce243b921f53ee63ba9bbe1',
 )
 
-JETTY_VERS = '9.2.14.v20151106'
+JETTY_VERS = '9.3.11.v20160721'
 
 maven_jar(
   name = 'jetty_servlet',
   artifact = 'org.eclipse.jetty:jetty-servlet:' + JETTY_VERS,
-  sha1 = '3a2cd4d8351a38c5d60e0eee010fee11d87483ef',
+  sha1 = 'd550147b85c73ea81084a4ac7915ba7f609021c5',
 )
 
 maven_jar(
   name = 'jetty_security',
   artifact = 'org.eclipse.jetty:jetty-security:' + JETTY_VERS,
-  sha1 = '2d36974323fcb31e54745c1527b996990835db67',
+  sha1 = '1cbefc5d1196b9e1ca6f4cc36738998a6ebde8bf',
 )
 
 maven_jar(
   name = 'jetty_servlets',
   artifact = 'org.eclipse.jetty:jetty-servlets:' + JETTY_VERS,
-  sha1 = 'a75c78a0ee544073457ca5ee9db20fdc6ed55225',
+  sha1 = 'a9f7a43977151a463aa21a9b0e882aa3d25452ef',
 )
 
 maven_jar(
   name = 'jetty_server',
   artifact = 'org.eclipse.jetty:jetty-server:' + JETTY_VERS,
-  sha1 = '70b22c1353e884accf6300093362b25993dac0f5',
+  sha1 = 'd932e0dc1e9bd4839ae446754615163d60271a66',
 )
 
 maven_jar(
   name = 'jetty_jmx',
   artifact = 'org.eclipse.jetty:jetty-jmx:' + JETTY_VERS,
-  sha1 = '617edc5e966b4149737811ef8b289cd94b831bab',
+  sha1 = '21a658d2f5eb87c23eef4911966625ea95f66d32',
 )
 
 maven_jar(
   name = 'jetty_continuation',
   artifact = 'org.eclipse.jetty:jetty-continuation:' + JETTY_VERS,
-  sha1 = '8909d62fd7e28351e2da30de6fb4105539b949c0',
+  sha1 = '92a91c0dcc5f5d779a1c9f94038332be3f46c9df',
 )
 
 maven_jar(
   name = 'jetty_http',
   artifact = 'org.eclipse.jetty:jetty-http:' + JETTY_VERS,
-  sha1 = '699ad1f2fa6fb0717e1b308a8c9e1b8c69d81ef6',
+  sha1 = 'dcfb95e5b886a981bb76467b911c5b706117f9cf',
 )
 
 maven_jar(
   name = 'jetty_io',
   artifact = 'org.eclipse.jetty:jetty-io:' + JETTY_VERS,
-  sha1 = 'dfa4137371a3f08769820138ca1a2184dacda267',
+  sha1 = 'db5f4f481159894a4b670072a34917b5414d0c98',
 )
 
 maven_jar(
   name = 'jetty_util',
   artifact = 'org.eclipse.jetty:jetty-util:' + JETTY_VERS,
-  sha1 = '0057e00b912ae0c35859ac81594a996007706a0b',
+  sha1 = '1812ffd5a04698051180d582c146ca807760c808',
 )
 
 maven_jar(
@@ -697,3 +813,262 @@
   artifact = 'xerces:xercesImpl:2.8.1',
   sha1 = '25101e37ec0c907db6f0612cbf106ee519c1aef1',
 )
+
+maven_jar(
+  name = 'postgresql',
+  artifact = 'postgresql:postgresql:9.1-901-1.jdbc4',
+  sha1 = '9bfabe48876ec38f6cbaa6931bad05c64a9ea942',
+)
+
+CM_VERSION = '5.19.0'
+
+maven_jar(
+  name = 'codemirror_minified',
+  artifact = 'org.webjars.npm:codemirror-minified:' + CM_VERSION,
+  sha1 = '263bf4acb7c4429be3fe46908af240f9f629d51c',
+)
+
+maven_jar(
+  name = 'codemirror_original',
+  artifact = 'org.webjars.npm:codemirror:' + CM_VERSION,
+  sha1 = 'e9ab382c6be240d55f112051bba3f6c637b798ce',
+)
+
+maven_jar(
+  name = 'diff_match_patch',
+  artifact = 'org.webjars:google-diff-match-patch:20121119-1',
+  sha1 = '0cf1782dbcb8359d95070da9176059a5a9d37709',
+)
+
+maven_jar(
+  name = 'commons_io',
+  artifact = 'commons-io:commons-io:1.4',
+  sha1 = 'a8762d07e76cfde2395257a5da47ba7c1dbd3dce',
+)
+
+maven_jar(
+  name = "asciidoctor",
+  artifact = "org.asciidoctor:asciidoctorj:1.5.4.1",
+  sha1 = "f7ddfb2bbed2f8da3f9ad0d1a5514f04b4274a5a",
+)
+
+maven_jar(
+  name = "jruby",
+  artifact = "org.jruby:jruby-complete:9.1.5.0",
+  sha1 = "00d0003e99da3c4d830b12c099691ce910c84e39",
+)
+
+maven_jar(
+  name = 'elasticsearch',
+  artifact = 'org.elasticsearch:elasticsearch:2.4.0',
+  sha1 = 'aeb9704a76fa8654c348f38fcbb993a952a7ab07',
+)
+
+# Java REST client for Elasticsearch.
+JEST_VERSION = '2.0.3'
+
+maven_jar(
+  name = 'jest_common',
+  artifact = 'io.searchbox:jest-common:' + JEST_VERSION,
+  sha1 = 'f304c66894aaf2f6c17a886bc826f09c7a161cf9',
+)
+
+maven_jar(
+  name = 'jest',
+  artifact = 'io.searchbox:jest:' + JEST_VERSION,
+  sha1 = 'b8f9ed1423489b361804e47f640515ea9f1fa08d',
+)
+
+maven_jar(
+  name = 'compress_lzf',
+  artifact = 'com.ning:compress-lzf:1.0.2',
+  sha1 = '62896e6fca184c79cc01a14d143f3ae2b4f4b4ae',
+)
+
+maven_jar(
+  name = 'hppc',
+  artifact = 'com.carrotsearch:hppc:0.7.1',
+  sha1 = '8b5057f74ea378c0150a1860874a3ebdcb713767',
+)
+
+maven_jar(
+  name = 'jsr166e',
+  artifact = 'com.twitter:jsr166e:1.1.0',
+  sha1 = '233098147123ee5ddcd39ffc57ff648be4b7e5b2',
+)
+
+maven_jar(
+  name = 'netty',
+  artifact = 'io.netty:netty:3.10.0.Final',
+  sha1 = 'ad61cd1bba067e6634ddd3e160edf0727391ac30',
+)
+
+maven_jar(
+  name = 't_digest',
+  artifact = 'com.tdunning:t-digest:3.0',
+  sha1 = '84ccf145ac2215e6bfa63baa3101c0af41017cfc',
+)
+
+maven_jar(
+  name = 'jna',
+  artifact = 'net.java.dev.jna:jna:4.1.0',
+  sha1 = '1c12d070e602efd8021891cdd7fd18bc129372d4',
+)
+
+JACKSON_VERSION = '2.6.6'
+
+maven_jar(
+  name = 'jackson_core',
+  artifact = 'com.fasterxml.jackson.core:jackson-core:' + JACKSON_VERSION,
+  sha1 = '02eb801df67aacaf5b1deb4ac626e1964508e47b',
+)
+
+maven_jar(
+  name = 'jackson_dataformat_smile',
+  artifact = 'com.fasterxml.jackson.dataformat:jackson-dataformat-smile:' + JACKSON_VERSION,
+  sha1 = 'ccbfc948748ed2754a58c1af9e0a02b5cc1aed69',
+)
+
+maven_jar(
+  name = 'jackson_dataformat_cbor',
+  artifact = 'com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:' + JACKSON_VERSION,
+  sha1 = '34c7b7ff495fc6b049612bdc9db0900a68e112f8',
+)
+
+maven_jar(
+  name = 'httpasyncclient',
+  artifact = 'org.apache.httpcomponents:httpasyncclient:4.1.2',
+  sha1 = '95aa3e6fb520191a0970a73cf09f62948ee614be',
+)
+
+maven_jar(
+  name = 'httpcore_nio',
+  artifact = 'org.apache.httpcomponents:httpcore-nio:' + HTTPCOMP_VERS,
+  sha1 = 'a8c5e3c3bfea5ce23fb647c335897e415eb442e3',
+)
+
+maven_jar(
+  name = 'httpcore_niossl',
+  artifact = 'org.apache.httpcomponents:httpcore-niossl:4.0-alpha6',
+  sha1 = '9c662e7247ca8ceb1de5de629f685c9ef3e4ab58',
+)
+load("//tools/bzl:js.bzl", "npm_binary", "bower_archive")
+
+npm_binary(
+  name = "bower",
+)
+
+npm_binary(
+  name = "vulcanize",
+  repository = "GERRIT",
+)
+
+npm_binary(
+  name = "crisper",
+  repository = "GERRIT",
+)
+
+# bower_archive() seed components.
+bower_archive(
+  name = 'iron-autogrow-textarea',
+  package = 'polymerelements/iron-autogrow-textarea',
+  version = '1.0.12',
+  sha1 = 'b9b6874c9a2b5be435557a827ff8bd6661672ee3',
+)
+
+bower_archive(
+  name = 'es6-promise',
+  package = 'stefanpenner/es6-promise',
+  version = '3.3.0',
+  sha1 = 'a3a797bb22132f1ef75f9a2556173f81870c2e53',
+)
+
+bower_archive(
+  name = 'fetch',
+  package = 'fetch',
+  version = '1.0.0',
+  sha1 = '1b05a2bb40c73232c2909dc196de7519fe4db7a9',
+)
+
+bower_archive(
+  name = 'iron-dropdown',
+  package = 'polymerelements/iron-dropdown',
+  version = '1.4.0',
+  sha1 = '63e3d669a09edaa31c4f05afc76b53b919ef0595',
+)
+
+bower_archive(
+  name = 'iron-input',
+  package = 'polymerelements/iron-input',
+  version = '1.0.10',
+  sha1 = '9bc0c8e81de2527125383cbcf74dd9f27e7fa9ac',
+)
+
+bower_archive(
+  name = 'iron-overlay-behavior',
+  package = 'polymerelements/iron-overlay-behavior',
+  version = '1.7.6',
+  sha1 = '83181085fda59446ce74fd0d5ca30c223f38ee4a',
+)
+
+bower_archive(
+  name = 'iron-selector',
+  package = 'polymerelements/iron-selector',
+  version = '1.5.2',
+  sha1 = 'c57235dfda7fbb987c20ad0e97aac70babf1a1bf',
+)
+
+bower_archive(
+  name = 'moment',
+  package = 'moment/moment',
+  version = '2.13.0',
+  sha1 = 'fc8ce2c799bab21f6ced7aff928244f4ca8880aa',
+)
+
+bower_archive(
+  name = 'page',
+  package = 'visionmedia/page.js',
+  version = '1.7.1',
+  sha1 = '51a05428dd4f68fae1df5f12d0e2b61ba67f7757',
+)
+
+bower_archive(
+  name = 'polymer',
+  package = 'polymer/polymer',
+  version = '1.4.0',
+  sha1 = 'b84725939ead7c7bdf9917b065f68ef8dc790d06',
+)
+
+bower_archive(
+  name = 'promise-polyfill',
+  package = 'polymerlabs/promise-polyfill',
+  version = '1.0.0',
+  sha1 = 'a3b598c06cbd7f441402e666ff748326030905d6',
+)
+
+# bower test stuff
+
+bower_archive(
+  name = 'iron-test-helpers',
+  package = 'polymerelements/iron-test-helpers',
+  version = '1.2.5',
+  sha1 = '433b03b106f5ff32049b84150cd70938e18b67ac',
+)
+
+bower_archive(
+  name = 'test-fixture',
+  package = 'polymerelements/test-fixture',
+  version = '1.1.1',
+  sha1 = 'e373bd21c069163c3a754e234d52c07c77b20d3c',
+)
+
+bower_archive(
+  name = 'web-component-tester',
+  package = 'web-component-tester',
+  version = '4.2.2',
+  sha1 = '54556000c33d9ed7949aa546c1b4a1531491a5f0',
+)
+
+# Bower component transitive dependencies.
+load("//lib/js:bower_archives.bzl", "load_bower_archives")
+load_bower_archives()
diff --git a/contrib/.pylintrc b/contrib/.pylintrc
deleted file mode 100644
index 9e8882e..0000000
--- a/contrib/.pylintrc
+++ /dev/null
@@ -1,301 +0,0 @@
-# lint Python modules using external checkers.
-#
-# This is the main checker controling the other ones and the reports
-# generation. It is itself both a raw checker and an astng checker in order
-# to:
-# * handle message activation / deactivation at the module level
-# * handle some basic but necessary stats'data (number of classes, methods...)
-#
-[MASTER]
-
-# Specify a configuration file.
-#rcfile=
-
-# Python code to execute, usually for sys.path manipulation such as
-# pygtk.require().
-#init-hook=
-
-# Profiled execution.
-profile=no
-
-# Add <file or directory> to the black list. It should be a base name, not a
-# path. You may set this option multiple times.
-ignore=SVN
-
-# Pickle collected data for later comparisons.
-persistent=yes
-
-# Set the cache size for astng objects.
-cache-size=500
-
-# List of plugins (as comma separated values of python modules names) to load,
-# usually to register additional checkers.
-load-plugins=
-
-
-[MESSAGES CONTROL]
-
-# Enable only checker(s) with the given id(s). This option conflicts with the
-# disable-checker option
-#enable-checker=
-
-# Enable all checker(s) except those with the given id(s). This option
-# conflicts with the enable-checker option
-#disable-checker=
-
-# Enable all messages in the listed categories.
-#enable-msg-cat=
-
-# Disable all messages in the listed categories.
-#disable-msg-cat=
-
-# Enable the message(s) with the given id(s).
-enable=RP0004
-
-# Disable the message(s) with the given id(s).
-disable=R0903,R0912,R0913,R0914,R0915,W0141,C0111,C0103,W0603,W0703,R0911,C0301,C0302,R0902,R0904,W0142,W0212,E1101,E1103,R0201,W0201,W0122,W0232,RP0001,RP0003,RP0101,RP0002,RP0401,RP0701,RP0801
-
-[REPORTS]
-
-# set the output format. Available formats are text, parseable, colorized, msvs
-# (visual studio) and html
-output-format=text
-
-# Include message's id in output
-include-ids=yes
-
-# Put messages in a separate file for each module / package specified on the
-# command line instead of printing them on stdout. Reports (if any) will be
-# written in a file name "pylint_global.[txt|html]".
-files-output=no
-
-# Tells whether to display a full report or only the messages
-reports=yes
-
-# Python expression which should return a note less than 10 (10 is the highest
-# note).You have access to the variables errors warning, statement which
-# respectivly contain the number of errors / warnings messages and the total
-# number of statements analyzed. This is used by the global evaluation report
-# (R0004).
-evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
-
-# Add a comment according to your evaluation note. This is used by the global
-# evaluation report (R0004).
-comment=no
-
-# checks for
-# * unused variables / imports
-# * undefined variables
-# * redefinition of variable from builtins or from an outer scope
-# * use of variable before assigment
-#
-[VARIABLES]
-
-# Tells whether we should check for unused import in __init__ files.
-init-import=no
-
-# A regular expression matching names used for dummy variables (i.e. not used).
-dummy-variables-rgx=_|dummy
-
-# List of additional names supposed to be defined in builtins. Remember that
-# you should avoid to define new builtins when possible.
-additional-builtins=
-
-
-# try to find bugs in the code using type inference
-#
-[TYPECHECK]
-
-# Tells whether missing members accessed in mixin class should be ignored. A
-# mixin class is detected if its name ends with "mixin" (case insensitive).
-ignore-mixin-members=yes
-
-# List of classes names for which member attributes should not be checked
-# (useful for classes with attributes dynamicaly set).
-ignored-classes=SQLObject
-
-# When zope mode is activated, consider the acquired-members option to ignore
-# access to some undefined attributes.
-zope=no
-
-# List of members which are usually get through zope's acquisition mecanism and
-# so shouldn't trigger E0201 when accessed (need zope=yes to be considered).
-acquired-members=REQUEST,acl_users,aq_parent
-
-
-# checks for :
-# * doc strings
-# * modules / classes / functions / methods / arguments / variables name
-# * number of arguments, local variables, branchs, returns and statements in
-# functions, methods
-# * required module attributes
-# * dangerous default values as arguments
-# * redefinition of function / method / class
-# * uses of the global statement
-#
-[BASIC]
-
-# Required attributes for module, separated by a comma
-required-attributes=
-
-# Regular expression which should only match functions or classes name which do
-# not require a docstring
-no-docstring-rgx=_main|__.*__
-
-# Regular expression which should only match correct module names
-module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
-
-# Regular expression which should only match correct module level names
-const-rgx=(([A-Z_][A-Z1-9_]*)|(__.*__))|(log)$
-
-# Regular expression which should only match correct class names
-class-rgx=[A-Z_][a-zA-Z0-9]+$
-
-# Regular expression which should only match correct function names
-function-rgx=[a-z_][a-z0-9_]{2,30}$
-
-# Regular expression which should only match correct method names
-method-rgx=[a-z_][a-z0-9_]{2,30}$
-
-# Regular expression which should only match correct instance attribute names
-attr-rgx=[a-z_][a-z0-9_]{2,30}$
-
-# Regular expression which should only match correct argument names
-argument-rgx=[a-z_][a-z0-9_]{2,30}$
-
-# Regular expression which should only match correct variable names
-variable-rgx=[a-z_][a-z0-9_]{2,30}$
-
-# Regular expression which should only match correct list comprehension /
-# generator expression variable names
-inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
-
-# Good variable names which should always be accepted, separated by a comma
-good-names=i,j,k,ex,Run,_,e,d1,d2,v,f,l,d
-
-# Bad variable names which should always be refused, separated by a comma
-bad-names=foo,bar,baz,toto,tutu,tata
-
-# List of builtins function names that should not be used, separated by a comma
-bad-functions=map,filter,apply,input
-
-
-# checks for sign of poor/misdesign:
-# * number of methods, attributes, local variables...
-# * size, complexity of functions, methods
-#
-[DESIGN]
-
-# Maximum number of arguments for function / method
-max-args=5
-
-# Maximum number of locals for function / method body
-max-locals=15
-
-# Maximum number of return / yield for function / method body
-max-returns=6
-
-# Maximum number of branch for function / method body
-max-branchs=12
-
-# Maximum number of statements in function / method body
-max-statements=50
-
-# Maximum number of parents for a class (see R0901).
-max-parents=7
-
-# Maximum number of attributes for a class (see R0902).
-max-attributes=20
-
-# Minimum number of public methods for a class (see R0903).
-min-public-methods=2
-
-# Maximum number of public methods for a class (see R0904).
-max-public-methods=30
-
-
-# checks for
-# * external modules dependencies
-# * relative / wildcard imports
-# * cyclic imports
-# * uses of deprecated modules
-#
-[IMPORTS]
-
-# Deprecated modules which should not be used, separated by a comma
-deprecated-modules=regsub,string,TERMIOS,Bastion,rexec
-
-# Create a graph of every (i.e. internal and external) dependencies in the
-# given file (report R0402 must not be disabled)
-import-graph=
-
-# Create a graph of external dependencies in the given file (report R0402 must
-# not be disabled)
-ext-import-graph=
-
-# Create a graph of internal dependencies in the given file (report R0402 must
-# not be disabled)
-int-import-graph=
-
-
-# checks for :
-# * methods without self as first argument
-# * overridden methods signature
-# * access only to existant members via self
-# * attributes not defined in the __init__ method
-# * supported interfaces implementation
-# * unreachable code
-#
-[CLASSES]
-
-# List of interface methods to ignore, separated by a comma. This is used for
-# instance to not check methods defines in Zope's Interface base class.
-ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by
-
-# List of method names used to declare (i.e. assign) instance attributes.
-defining-attr-methods=__init__,__new__,setUp
-
-
-# checks for similarities and duplicated code. This computation may be
-# memory / CPU intensive, so you should disable it if you experiments some
-# problems.
-#
-[SIMILARITIES]
-
-# Minimum lines number of a similarity.
-min-similarity-lines=4
-
-# Ignore comments when computing similarities.
-ignore-comments=yes
-
-# Ignore docstrings when computing similarities.
-ignore-docstrings=yes
-
-
-# checks for:
-# * warning notes in the code like FIXME, XXX
-# * PEP 263: source code with non ascii character but no encoding declaration
-#
-[MISCELLANEOUS]
-
-# List of note tags to take in consideration, separated by a comma.
-notes=FIXME,XXX,TODO
-
-
-# checks for :
-# * unauthorized constructions
-# * strict indentation
-# * line length
-# * use of <> instead of !=
-#
-[FORMAT]
-
-# Maximum number of characters on a single line.
-max-line-length=80
-
-# Maximum number of lines in a module
-max-module-lines=1000
-
-# String used as indentation unit. This is usually "    " (4 spaces) or "\t" (1
-# tab).  In repo it is 2 spaces.
-indent-string='  '
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index 5f5b9ef..f62c767 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -38,7 +38,7 @@
 Supports dry-run mode to only list the stale changes but not actually
 abandon them.
 
-Requires pygerrit (https://github.com/sonyxperiadev/pygerrit).
+Requires pygerrit2 (https://github.com/dpursehouse/pygerrit2).
 
 """
 
@@ -47,8 +47,8 @@
 import re
 import sys
 
-from pygerrit.rest import GerritRestAPI
-from pygerrit.rest.auth import HTTPBasicAuthFromNetrc, HTTPDigestAuthFromNetrc
+from pygerrit2.rest import GerritRestAPI
+from pygerrit2.rest.auth import HTTPBasicAuthFromNetrc, HTTPDigestAuthFromNetrc
 
 
 def _main():
diff --git a/contrib/build-consistency.go b/contrib/build-consistency.go
new file mode 100644
index 0000000..db63a27
--- /dev/null
+++ b/contrib/build-consistency.go
@@ -0,0 +1,108 @@
+// Copyright (C) 2016 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 main
+
+import (
+	"fmt"
+	"io/ioutil"
+	"log"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+)
+
+var (
+	// Define regex to find a comment in the build files
+	commentRE = regexp.MustCompile("#.*")
+	// Define regexes to extract the lib name and sha1
+	mvnRE     = regexp.MustCompile("maven_jar([^)]*)")
+	sha1RE    = regexp.MustCompile("sha1=[\"'](?P<SHA1>[^,]*)[\"']")
+	bSha1RE   = regexp.MustCompile("bin_sha1=[\"'](?P<SHA1>[^,]*)[\"']")
+	libNameRE = regexp.MustCompile("name=[\"'](?P<NAME>[^,]*)[\"']")
+)
+
+func sanitize(s string) string {
+	// Strip out comments
+	s = commentRE.ReplaceAllString(s, "")
+	// Remove newlines and blanks
+	s = strings.Replace(s, "\n", "", -1)
+	s = strings.Replace(s, " ", "", -1)
+	// WORKSPACE syntax disallows the dash char in artifact name and we use an underscore
+	// So we make this a consistent underscore in all files
+	s = strings.Replace(s, "-", "_", -1)
+	return s
+}
+
+func main() {
+	// Load bazel WORKSPACE file
+	bzlDat, err := ioutil.ReadFile("WORKSPACE")
+	if err != nil {
+		log.Fatal(err)
+	}
+	bzlStr := sanitize(string(bzlDat))
+
+	// Walk all files nested under lib. Find, load and sanitize BUCK files
+	bckStrs := []string{}
+	err = filepath.Walk("lib/", func(path string, f os.FileInfo, err error) error {
+		bckFile := filepath.Join(path, "BUCK")
+		if _, err := os.Stat(bckFile); err == nil {
+			bckDat, err := ioutil.ReadFile(bckFile)
+			if err != nil {
+				return err
+			}
+			bckStrs = append(bckStrs, sanitize(string(bckDat)))
+		}
+		return nil
+	})
+	if err != nil {
+		log.Fatal(err)
+	}
+	bckStr := strings.Join(bckStrs, "")
+
+	// Find all bazel dependencies
+	// bzlVersions maps from a lib name to the referenced sha1
+	bzlVersions := make(map[string]string)
+	for _, mvn := range mvnRE.FindAllString(bzlStr, -1) {
+		sha1s := sha1RE.FindStringSubmatch(mvn)
+		names := libNameRE.FindStringSubmatch(mvn)
+		if len(sha1s) > 1 && len(names) > 1 {
+			bzlVersions[names[1]] = sha1RE.FindStringSubmatch(mvn)[1]
+		} else {
+			fmt.Printf("Can't parse lib sha1/name of target %s\n", mvn)
+		}
+	}
+
+	// Find all buck dependencies and check if we have the correct bazel dependency on file
+	for _, mvn := range mvnRE.FindAllString(bckStr, -1) {
+		sha1s := bSha1RE.FindStringSubmatch(mvn)
+		if len(sha1s) < 2 {
+			// Buck knows two dep version representations: just a SHA1 or a bin_sha1 and src_sha1
+			// We try to extract the bin_sha1 first. If that fails, we use the sha1
+			sha1s = sha1RE.FindStringSubmatch(mvn)
+		}
+		names := libNameRE.FindStringSubmatch(mvn)
+		if len(sha1s) > 1 && len(names) > 1 {
+			if _, ok := bzlVersions[names[1]]; !ok {
+				// TODO(hiesel) This produces too many false positives.
+				//fmt.Printf("Don't have lib %s in bazel\n", names[1])
+			} else if bzlVersions[names[1]] != sha1s[1] {
+				fmt.Printf("SHA1 of lib %s does not match: buck has %s while bazel has %s\n", names[1], sha1s[1], bzlVersions[names[1]])
+			}
+		} else {
+			fmt.Printf("Can't parse lib sha1/name on target %s\n", mvn)
+		}
+	}
+}
diff --git a/contrib/git-push-review b/contrib/git-push-review
index e77785a..87eaa4c 100755
--- a/contrib/git-push-review
+++ b/contrib/git-push-review
@@ -46,8 +46,8 @@
                  help='remote name or URL to push to')
   p.add_argument('-b', '--branch', default='', metavar='BRANCH',
                  help='remote branch name, refs/for/BRANCH')
-  p.add_argument('reviewers', nargs='*', metavar='REVIEWER',
-                 help='reviewer names or aliases')
+  p.add_argument('args', nargs='*', metavar='REVIEWER_OR_HASHTAG',
+                 help='reviewer names or aliases, or #hashtags')
   p.add_argument('-t', '--topic', default='', metavar='TOPIC',
                  help='topic for new changes')
   p.add_argument('--dry-run', action='store_true',
@@ -68,8 +68,13 @@
     args.remote = args.remote or def_remote
     args.branch = args.branch or def_branch
 
+
   opts = collections.defaultdict(list)
-  opts['r'].extend((get_config('reviewer.' + r) or r) for r in args.reviewers)
+  is_hashtag = lambda x: x.startswith('#')
+  opts['r'].extend(
+      (get_config('reviewer.' + r) or r)
+      for r in args.args if not is_hashtag(r))
+  opts['t'].extend(t[1:] for t in args.args if is_hashtag(t))
   if args.topic:
     opts['topic'].append(args.topic)
   opts_str = ','.join('%s=%s' % (k, v) for k in opts for v in opts[k])
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
index c35f82c..b77c41a 100644
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -182,14 +182,15 @@
 
 
 def get_random_users(num_users):
-  users = [(f, l) for f in FIRST_NAMES for l in LAST_NAMES][:num_users]
+  users = random.sample([(f, l) for f in FIRST_NAMES for l in LAST_NAMES],
+                        num_users)
   names = []
   for u in users:
     names.append({"firstname": u[0],
                   "lastname": u[1],
                   "name": u[0] + " " + u[1],
                   "username": u[0] + u[1],
-                  "email": u[0] + "." + u[1] + "@gmail.com",
+                  "email": u[0] + "." + u[1] + "@gerritcodereview.com",
                   "http_password": "secret",
                   "groups": []})
   return names
@@ -293,6 +294,7 @@
   project_names = create_gerrit_projects(group_names)
 
   for idx, u in enumerate(gerrit_users):
-    create_change(u, project_names[4 * idx / len(gerrit_users)])
+    for _ in xrange(random.randint(1, 5)):
+      create_change(u, project_names[4 * idx / len(gerrit_users)])
 
 main()
diff --git a/gerrit-acceptance-framework/BUCK b/gerrit-acceptance-framework/BUCK
index ba68fa3..89f8ea3 100644
--- a/gerrit-acceptance-framework/BUCK
+++ b/gerrit-acceptance-framework/BUCK
@@ -1,24 +1,5 @@
 SRCS = glob(['src/test/java/com/google/gerrit/acceptance/*.java'])
 
-DEPS = [
-  '//gerrit-gpg:gpg',
-  '//gerrit-launcher:launcher',
-  '//gerrit-openid:openid',
-  '//gerrit-pgm:daemon',
-  '//gerrit-pgm:http-jetty',
-  '//gerrit-pgm:util-nodep',
-  '//gerrit-server/src/main/prolog:common',
-  '//gerrit-server:testutil',
-  '//lib/auto:auto-value',
-  '//lib/httpcomponents:fluent-hc',
-  '//lib/httpcomponents:httpclient',
-  '//lib/httpcomponents:httpcore',
-  '//lib/jetty:servlet',
-  '//lib/jgit/org.eclipse.jgit.junit:junit',
-  '//lib/log:impl_log4j',
-  '//lib/log:log4j',
-]
-
 PROVIDED = [
   '//gerrit-common:annotations',
   '//gerrit-common:server',
@@ -37,14 +18,41 @@
 
 java_binary(
   name = 'acceptance-framework',
+  merge_manifests = False,
+  manifest_file = ':manifest',
   deps = [':lib'],
   visibility = ['PUBLIC'],
 )
 
+genrule(
+  name = 'manifest',
+  cmd = 'echo "Manifest-Version: 1.0" >$OUT;' +
+    'echo "Implementation-Title: Gerrit Acceptance Test Framework" >>$OUT;' +
+    'echo "Implementation-Vendor: Gerrit Code Review Project" >>$OUT',
+  out = 'manifest.txt',
+)
+
 java_library(
   name = 'lib',
   srcs = SRCS,
-  exported_deps = DEPS + [
+  exported_deps = [
+    '//gerrit-antlr:query_exception',
+    '//gerrit-gpg:gpg',
+    '//gerrit-launcher:launcher',
+    '//gerrit-openid:openid',
+    '//gerrit-pgm:daemon',
+    '//gerrit-pgm:http-jetty',
+    '//gerrit-pgm:util-nodep',
+    '//gerrit-server/src/main/prolog:common',
+    '//gerrit-server:testutil',
+    '//lib/auto:auto-value',
+    '//lib/httpcomponents:fluent-hc',
+    '//lib/httpcomponents:httpclient',
+    '//lib/httpcomponents:httpcore',
+    '//lib/jetty:servlet',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
+    '//lib/log:impl_log4j',
+    '//lib/log:log4j',
     '//lib:truth',
   ],
   provided_deps = PROVIDED + [
@@ -57,18 +65,8 @@
 )
 
 java_sources(
-  name = 'src',
-  srcs = SRCS,
-  visibility = ['PUBLIC'],
-)
-
-# The above java_sources produces a .jar somewhere in the depths of
-# buck-out, but it does not bring it to
-# buck-out/gen/gerrit-acceptance-framework/gerrit-acceptance-framework-src.jar.
-# We fix that by the following java_binary.
-java_binary(
   name = 'acceptance-framework-src',
-  deps = [ ':src' ],
+  srcs = SRCS,
   visibility = ['PUBLIC'],
 )
 
@@ -76,9 +74,10 @@
   name = 'acceptance-framework-javadoc',
   title = 'Gerrit Acceptance Test Framework Documentation',
   pkgs = [' com.google.gerrit.acceptance'],
-  paths = ['src/test/java'],
+  source_jar = ':acceptance-framework-src',
   srcs = SRCS,
-  deps = DEPS + PROVIDED + [
+  deps = PROVIDED + [
+    ':lib',
     '//lib:guava',
     '//lib/guice:guice-assistedinject',
     '//lib/guice:guice_library',
@@ -86,7 +85,6 @@
     '//lib/guice:javax-inject',
     '//lib:gwtorm_client',
     '//lib:junit',
-    '//lib:truth',
   ],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-acceptance-framework/BUILD b/gerrit-acceptance-framework/BUILD
index 1439ba9..ec79be8 100644
--- a/gerrit-acceptance-framework/BUILD
+++ b/gerrit-acceptance-framework/BUILD
@@ -2,25 +2,6 @@
 
 SRCS = glob(['src/test/java/com/google/gerrit/acceptance/*.java'])
 
-DEPS = [
-  '//gerrit-gpg:gpg',
-  '//gerrit-launcher:launcher',
-  '//gerrit-openid:openid',
-  '//gerrit-pgm:daemon',
-  '//gerrit-pgm:http-jetty',
-  '//gerrit-pgm:util-nodep',
-  '//gerrit-server/src/main/prolog:common',
-  '//gerrit-server:testutil',
-  '//lib/auto:auto-value',
-  '//lib/httpcomponents:fluent-hc',
-  '//lib/httpcomponents:httpclient',
-  '//lib/httpcomponents:httpcore',
-  '//lib/jetty:servlet',
-  '//lib/jgit/org.eclipse.jgit.junit:junit',
-  '//lib/log:impl_log4j',
-  '//lib/log:log4j',
-]
-
 PROVIDED = [
   '//gerrit-common:annotations',
   '//gerrit-common:server',
@@ -47,7 +28,24 @@
 java_library2(
   name = 'lib',
   srcs = SRCS,
-  exported_deps = DEPS + [
+  exported_deps = [
+    '//gerrit-antlr:query_exception',
+    '//gerrit-gpg:gpg',
+    '//gerrit-launcher:launcher',
+    '//gerrit-openid:openid',
+    '//gerrit-pgm:daemon',
+    '//gerrit-pgm:http-jetty',
+    '//gerrit-pgm:util-nodep',
+    '//gerrit-server/src/main/prolog:common',
+    '//gerrit-server:testutil',
+    '//lib/auto:auto-value',
+    '//lib/httpcomponents:fluent-hc',
+    '//lib/httpcomponents:httpclient',
+    '//lib/httpcomponents:httpcore',
+    '//lib/jetty:servlet',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
+    '//lib/log:impl_log4j',
+    '//lib/log:log4j',
     '//lib:truth',
   ],
   deps = PROVIDED + [ # We want these deps to be exported_deps
@@ -58,3 +56,13 @@
   ],
   visibility = ['//visibility:public'],
 )
+
+load('//tools/bzl:javadoc.bzl', 'java_doc')
+
+java_doc(
+  name = 'acceptance-framework-javadoc',
+  title = 'Gerrit Acceptance Test Framework Documentation',
+  libs = [':lib'],
+  pkgs = ['com.google.gerrit.acceptance'],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-acceptance-framework/pom.xml b/gerrit-acceptance-framework/pom.xml
index 6a4fdf2..d9d701c 100644
--- a/gerrit-acceptance-framework/pom.xml
+++ b/gerrit-acceptance-framework/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>2.13.2</version>
+  <version>2.14-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index ae480c7..1910049 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -17,25 +17,29 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.initSsh;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
+import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Iterators;
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -45,7 +49,10 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -61,7 +68,9 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.change.Abandon;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.Revisions;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -70,8 +79,10 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.mail.EmailHeader;
+import com.google.gerrit.server.mail.send.EmailHeader;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
@@ -97,12 +108,19 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.FetchResult;
+import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.Transport;
+import org.eclipse.jgit.transport.TransportBundleStream;
+import org.eclipse.jgit.transport.URIish;
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
@@ -114,14 +132,21 @@
 import org.junit.runner.RunWith;
 import org.junit.runners.model.Statement;
 
+import java.io.File;
+import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.regex.Pattern;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
 
 @RunWith(ConfigSuite.class)
 public abstract class AbstractDaemonTest {
@@ -217,6 +242,9 @@
   @Inject
   private EventRecorder.Factory eventRecorderFactory;
 
+  @Inject
+  private ChangeIndexCollection changeIndexes;
+
   protected TestRepository<InMemoryRepository> testRepo;
   protected GerritServer server;
   protected TestAccount admin;
@@ -235,6 +263,9 @@
   @Inject
   protected ChangeNotes.Factory notesFactory;
 
+  @Inject
+  protected Abandon changeAbandoner;
+
   @Rule
   public ExpectedException exception = ExpectedException.none();
 
@@ -322,7 +353,8 @@
     baseConfig.setString("gerrit", null, "tempSiteDir",
         tempSiteDir.getRoot().getPath());
     baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
-    if (classDesc.equals(methodDesc)) {
+    if (classDesc.equals(methodDesc) && !classDesc.sandboxed() &&
+        !methodDesc.sandboxed()) {
       if (commonServer == null) {
         commonServer = GerritServer.start(classDesc, baseConfig);
       }
@@ -520,21 +552,26 @@
 
   protected PushOneCommit.Result createMergeCommitChange(String ref)
       throws Exception {
+    return createMergeCommitChange(ref, "foo");
+  }
+
+  protected PushOneCommit.Result createMergeCommitChange(String ref, String file)
+      throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
     PushOneCommit.Result p1 = pushFactory.create(db, admin.getIdent(),
-        testRepo, "parent 1", ImmutableMap.of("foo", "foo-1", "bar", "bar-1"))
+        testRepo, "parent 1", ImmutableMap.of(file, "foo-1", "bar", "bar-1"))
         .to(ref);
 
     // reset HEAD in order to create a sibling of the first change
     testRepo.reset(initial);
 
     PushOneCommit.Result p2 = pushFactory.create(db, admin.getIdent(),
-        testRepo, "parent 2", ImmutableMap.of("foo", "foo-2", "bar", "bar-2"))
+        testRepo, "parent 2", ImmutableMap.of(file, "foo-2", "bar", "bar-2"))
         .to(ref);
 
     PushOneCommit m = pushFactory.create(db, admin.getIdent(), testRepo, "merge",
-        ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+        ImmutableMap.of(file, "foo-1", "bar", "bar-2"));
     m.setParents(ImmutableList.of(p1.getCommit(), p2.getCommit()));
     PushOneCommit.Result result = m.to(ref);
     result.assertOkStatus();
@@ -666,6 +703,22 @@
     atrScope.set(preDisableContext);
   }
 
+  protected void disableChangeIndexWrites() {
+    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
+      if (!(i instanceof ReadOnlyChangeIndex)) {
+        changeIndexes.addWriteIndex(new ReadOnlyChangeIndex(i));
+      }
+    }
+  }
+
+  protected void enableChangeIndexWrites() {
+    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
+      if (i instanceof ReadOnlyChangeIndex) {
+        changeIndexes.addWriteIndex(((ReadOnlyChangeIndex)i).unwrap());
+      }
+    }
+  }
+
   protected static Gson newGson() {
     return OutputFormat.JSON_COMPACT.newGson();
   }
@@ -800,6 +853,19 @@
     }
   }
 
+  protected void removePermission(String permission, Project.NameKey project,
+      String ref) throws IOException, ConfigInvalidException {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      md.setMessage(String.format("Remove %s on %s", permission, ref));
+      ProjectConfig config = ProjectConfig.read(md);
+      AccessSection s = config.getAccessSection(ref, true);
+      Permission p = s.getPermission(permission, true);
+      p.getRules().clear();
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
   protected void blockRead(String ref) throws Exception {
     block(Permission.READ, REGISTERED_USERS, ref);
   }
@@ -831,13 +897,7 @@
   }
 
   private static Iterable<String> changeIds(Iterable<ChangeInfo> changes) {
-    return Iterables.transform(changes,
-        new Function<ChangeInfo, String>() {
-          @Override
-          public String apply(ChangeInfo input) {
-            return input.changeId;
-          }
-        });
+    return Iterables.transform(changes, i -> i.changeId);
   }
 
   protected void assertSubmittedTogether(String chId, String... expected)
@@ -908,7 +968,8 @@
 
   protected RevCommit getHead(Repository repo, String name) throws Exception {
     try (RevWalk rw = new RevWalk(repo)) {
-      return rw.parseCommit(repo.exactRef(name).getObjectId());
+      Ref r = repo.exactRef(name);
+      return r != null ? rw.parseCommit(r.getObjectId()) : null;
     }
   }
 
@@ -940,4 +1001,141 @@
         (EmailHeader.String)message.headers().get("Reply-To");
     assertThat(replyTo.getString()).isEqualTo(email);
   }
+
+  protected ContributorAgreement configureContributorAgreement(
+      boolean autoVerify) throws Exception {
+    ContributorAgreement ca;
+    if (autoVerify) {
+      String g = createGroup("cla-test-group");
+      GroupApi groupApi = gApi.groups().id(g);
+      groupApi.description("CLA test group");
+      AccountGroup caGroup = groupCache.get(
+          new AccountGroup.UUID(groupApi.detail().id));
+      GroupReference groupRef = GroupReference.forGroup(caGroup);
+      PermissionRule rule = new PermissionRule(groupRef);
+      rule.setAction(PermissionRule.Action.ALLOW);
+      ca = new ContributorAgreement("cla-test");
+      ca.setAutoVerify(groupRef);
+      ca.setAccepted(ImmutableList.of(rule));
+    } else {
+      ca = new ContributorAgreement("cla-test-no-auto-verify");
+    }
+    ca.setDescription("description");
+    ca.setAgreementUrl("agreement-url");
+
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    cfg.replace(ca);
+    saveProjectConfig(allProjects, cfg);
+    return ca;
+  }
+
+  /**
+   * Fetches each bundle into a newly cloned repository, then it applies
+   * the bundle, and returns the resulting tree id.
+   */
+  protected Map<Branch.NameKey, RevTree>
+      fetchFromBundles(BinaryResult bundles) throws Exception {
+
+    assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
+
+    File tempfile = File.createTempFile("test", null);
+    bundles.writeTo(new FileOutputStream(tempfile));
+
+    Map<Branch.NameKey, RevTree> ret = new HashMap<>();
+    try (ZipFile readback = new ZipFile(tempfile);) {
+      for (ZipEntry entry : ImmutableList.copyOf(
+          Iterators.forEnumeration(readback.entries()))) {
+        String bundleName = entry.getName();
+        InputStream bundleStream = readback.getInputStream(entry);
+
+        int len = bundleName.length();
+        assertThat(bundleName).endsWith(".git");
+        String repoName = bundleName.substring(0, len - 4);
+        Project.NameKey proj = new Project.NameKey(repoName);
+        TestRepository<?> localRepo = cloneProject(proj);
+
+        try (TransportBundleStream tbs = new TransportBundleStream(
+            localRepo.getRepository(), new URIish(bundleName), bundleStream);) {
+
+          FetchResult fr = tbs.fetch(NullProgressMonitor.INSTANCE,
+              Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
+          for (Ref r : fr.getAdvertisedRefs()) {
+            String branchName = r.getName();
+            Branch.NameKey n = new Branch.NameKey(proj, branchName);
+
+            RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
+            ret.put(n, c.getTree());
+          }
+        }
+      }
+    }
+    return ret;
+  }
+
+  /**
+   * Assert that the given branches have the given tree ids.
+   */
+  protected void assertRevTrees(Project.NameKey proj,
+      Map<Branch.NameKey, RevTree> trees) throws Exception {
+    TestRepository<?> localRepo = cloneProject(proj);
+    GitUtil.fetch(localRepo, "refs/*:refs/*");
+    Map<String, Ref> refs = localRepo.getRepository().getAllRefs();
+    Map<Branch.NameKey, RevTree> refValues = new HashMap<>();
+
+    for (Branch.NameKey b : trees.keySet()) {
+      if (!b.getParentKey().equals(proj)) {
+        continue;
+      }
+
+      Ref r = refs.get(b.get());
+      assertThat(r).isNotNull();
+      RevWalk rw = localRepo.getRevWalk();
+      RevCommit c = rw.parseCommit(r.getObjectId());
+      refValues.put(b, c.getTree());
+
+      assertThat(trees.get(b)).isEqualTo(refValues.get(b));
+    }
+    assertThat(refValues.keySet()).containsAnyIn(trees.keySet());
+  }
+
+  protected void assertDiffForNewFile(DiffInfo diff, RevCommit commit,
+      String path, String expectedContentSideB) throws Exception {
+    List<String> expectedLines = new ArrayList<>();
+    for (String line : expectedContentSideB.split("\n")) {
+      expectedLines.add(line);
+    }
+
+    assertThat(diff.binary).isNull();
+    assertThat(diff.changeType).isEqualTo(ChangeType.ADDED);
+    assertThat(diff.diffHeader).isNotNull();
+    assertThat(diff.intralineStatus).isNull();
+    assertThat(diff.webLinks).isNull();
+
+    assertThat(diff.metaA).isNull();
+    assertThat(diff.metaB).isNotNull();
+    assertThat(diff.metaB.commitId).isEqualTo(commit.name());
+
+    String expectedContentType = "text/plain";
+    if (COMMIT_MSG.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE;
+    } else if (MERGE_LIST.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_MERGE_LIST;
+    }
+    assertThat(diff.metaB.contentType).isEqualTo(expectedContentType);
+
+    assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
+    assertThat(diff.metaB.name).isEqualTo(path);
+    assertThat(diff.metaB.webLinks).isNull();
+
+    assertThat(diff.content).hasSize(1);
+    DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+    assertThat(contentEntry.b).containsExactlyElementsIn(expectedLines)
+        .inOrder();
+    assertThat(contentEntry.a).isNull();
+    assertThat(contentEntry.ab).isNull();
+    assertThat(contentEntry.common).isNull();
+    assertThat(contentEntry.editA).isNull();
+    assertThat(contentEntry.editB).isNull();
+    assertThat(contentEntry.skip).isNull();
+  }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
index bce0b5a..114ef6a 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.US_ASCII;
 
@@ -104,6 +105,7 @@
         for (String n : groups) {
           AccountGroup.NameKey k = new AccountGroup.NameKey(n);
           AccountGroup g = groupCache.get(k);
+          checkArgument(g != null, "group not found: %s", n);
           AccountGroupMember m =
               new AccountGroupMember(new AccountGroupMember.Key(id, g.getId()));
           db.accountGroupMembers().insert(Collections.singleton(m));
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
index 6cc8d3c..fbdfee6 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.LinkedListMultimap;
@@ -90,16 +89,6 @@
     return String.format("%s-%s-%s", type, project, ref);
   }
 
-  private static class RefEventTransformer<T extends RefEvent>
-      implements Function<RefEvent, T> {
-
-    @SuppressWarnings("unchecked")
-    @Override
-    public T apply(RefEvent e) {
-      return (T) e;
-    }
-  }
-
   private ImmutableList<RefUpdatedEvent> getRefUpdatedEvents(String project,
       String refName, int expectedSize) {
     String key = refEventKey(RefUpdatedEvent.TYPE, project, refName);
@@ -111,7 +100,7 @@
     assertThat(recordedEvents).containsKey(key);
     ImmutableList<RefUpdatedEvent> events = FluentIterable
         .from(recordedEvents.get(key))
-        .transform(new RefEventTransformer<RefUpdatedEvent>())
+        .transform(RefUpdatedEvent.class::cast)
         .toList();
     assertThat(events).hasSize(expectedSize);
     return events;
@@ -128,7 +117,7 @@
     assertThat(recordedEvents).containsKey(key);
     ImmutableList<ChangeMergedEvent> events = FluentIterable
         .from(recordedEvents.get(key))
-        .transform(new RefEventTransformer<ChangeMergedEvent>())
+        .transform(ChangeMergedEvent.class::cast)
         .toList();
     assertThat(events).hasSize(expectedSize);
     return events;
@@ -144,7 +133,7 @@
     assertThat(recordedEvents).containsKey(key);
     ImmutableList<ReviewerDeletedEvent> events = FluentIterable
         .from(recordedEvents.get(key))
-        .transform(new RefEventTransformer<ReviewerDeletedEvent>())
+        .transform(ReviewerDeletedEvent.class::cast)
         .toList();
     assertThat(events).hasSize(expectedSize);
     return events;
@@ -217,4 +206,4 @@
   public void close() {
     eventListenerRegistration.remove();
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfig.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfig.java
index 4b956a2..4e40d92 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfig.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfig.java
@@ -17,11 +17,13 @@
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
+import java.lang.annotation.Repeatable;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
 @Target({METHOD})
 @Retention(RUNTIME)
+@Repeatable(GerritConfigs.class)
 public @interface GerritConfig {
   String name();
   String value() default "";
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
index c4e636b..c29e8fe 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
@@ -42,10 +42,12 @@
 import org.eclipse.jgit.util.FS;
 
 import java.io.File;
+import java.lang.annotation.Annotation;
 import java.lang.reflect.Field;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.URI;
+import java.nio.file.Paths;
 import java.util.concurrent.BrokenBarrierException;
 import java.util.concurrent.Callable;
 import java.util.concurrent.CyclicBarrier;
@@ -61,7 +63,8 @@
       return new AutoValue_GerritServer_Description(
           configName,
           true, // @UseLocalDisk is only valid on methods.
-          !hasNoHttpd(testDesc.getTestClass()),
+          !has(NoHttpd.class, testDesc.getTestClass()),
+          has(Sandboxed.class, testDesc.getTestClass()),
           null, // @GerritConfig is only valid on methods.
           null); // @GerritConfigs is only valid on methods.
 
@@ -73,14 +76,17 @@
           configName,
           testDesc.getAnnotation(UseLocalDisk.class) == null,
           testDesc.getAnnotation(NoHttpd.class) == null
-            && !hasNoHttpd(testDesc.getTestClass()),
+            && !has(NoHttpd.class, testDesc.getTestClass()),
+          testDesc.getAnnotation(Sandboxed.class) != null ||
+              has(Sandboxed.class, testDesc.getTestClass()),
           testDesc.getAnnotation(GerritConfig.class),
           testDesc.getAnnotation(GerritConfigs.class));
     }
 
-    private static boolean hasNoHttpd(Class<?> clazz) {
+    private static boolean has(
+        Class<? extends Annotation> annotation, Class<?> clazz) {
       for (; clazz != null; clazz = clazz.getSuperclass()) {
-        if (clazz.getAnnotation(NoHttpd.class) != null) {
+        if (clazz.getAnnotation(annotation) != null) {
           return true;
         }
       }
@@ -90,6 +96,7 @@
     @Nullable abstract String configName();
     abstract boolean memory();
     abstract boolean httpd();
+    abstract boolean sandboxed();
     @Nullable abstract GerritConfig config();
     @Nullable abstract GerritConfigs configs();
 
@@ -123,7 +130,7 @@
           throw new RuntimeException(e);
         }
       }
-    });
+    }, Paths.get(baseConfig.getString("gerrit", null, "tempSiteDir")));
     daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
 
     final File site;
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
index 0196d1f..2f1463d 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
@@ -16,8 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.base.Optional;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.reviewdb.client.Project;
@@ -28,12 +28,15 @@
 
 import org.eclipse.jgit.api.FetchCommand;
 import org.eclipse.jgit.api.PushCommand;
+import org.eclipse.jgit.api.TagCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.FetchResult;
@@ -47,6 +50,7 @@
 
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
 import java.util.Properties;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -137,6 +141,26 @@
     return cloneProject(project, sshSession.getUrl() + "/" + project.get());
   }
 
+  public static Ref createAnnotatedTag(TestRepository<?> testRepo, String name,
+      PersonIdent tagger) throws GitAPIException {
+    TagCommand cmd = testRepo.git().tag()
+        .setName(name)
+        .setAnnotated(true)
+        .setMessage(name)
+        .setTagger(tagger);
+    return cmd.call();
+  }
+
+  public static Ref updateAnnotatedTag(TestRepository<?> testRepo, String name,
+      PersonIdent tagger) throws GitAPIException {
+    TagCommand tc = testRepo.git().tag().setName(name);
+    return tc.setAnnotated(true)
+        .setMessage(name)
+        .setTagger(tagger)
+        .setForceUpdate(true)
+        .call();
+  }
+
   public static void fetch(TestRepository<?> testRepo, String spec)
       throws GitAPIException {
     FetchCommand fetch = testRepo.git().fetch();
@@ -144,6 +168,11 @@
     fetch.call();
   }
 
+  public static PushResult pushHead(TestRepository<?> testRepo, String ref)
+      throws GitAPIException {
+    return pushHead(testRepo, ref, false);
+  }
+
   public static PushResult pushHead(TestRepository<?> testRepo, String ref,
       boolean pushTags) throws GitAPIException {
     return pushHead(testRepo, ref, pushTags, false);
@@ -151,9 +180,27 @@
 
   public static PushResult pushHead(TestRepository<?> testRepo, String ref,
       boolean pushTags, boolean force) throws GitAPIException {
+    return pushOne(testRepo, "HEAD", ref, pushTags, force, null);
+  }
+
+  public static PushResult pushHead(TestRepository<?> testRepo, String ref,
+      boolean pushTags, boolean force, List<String> pushOptions)
+          throws GitAPIException {
+    return pushOne(testRepo, "HEAD", ref, pushTags, force, pushOptions);
+  }
+
+  public static PushResult deleteRef(TestRepository<?> testRepo, String ref)
+      throws GitAPIException {
+    return pushOne(testRepo, "", ref, false, true, null);
+  }
+
+  public static PushResult pushOne(TestRepository<?> testRepo, String source,
+      String target, boolean pushTags, boolean force, List<String> pushOptions)
+          throws GitAPIException {
     PushCommand pushCmd = testRepo.git().push();
     pushCmd.setForce(force);
-    pushCmd.setRefSpecs(new RefSpec("HEAD:" + ref));
+    pushCmd.setPushOptions(pushOptions);
+    pushCmd.setRefSpecs(new RefSpec(source + ":" + target));
     if (pushTags) {
       pushCmd.setPushTags();
     }
@@ -175,14 +222,25 @@
     assertThat(rru.getMessage()).isEqualTo(expectedMessage);
   }
 
+  public static PushResult pushTag(TestRepository<?> testRepo, String tag)
+      throws GitAPIException {
+    return pushTag(testRepo, tag, false);
+  }
+
+  public static PushResult pushTag(TestRepository<?> testRepo, String tag,
+      boolean force) throws GitAPIException {
+    PushCommand pushCmd = testRepo.git().push();
+    pushCmd.setForce(force);
+    pushCmd.setRefSpecs(new RefSpec("refs/tags/" + tag + ":refs/tags/" + tag));
+    Iterable<PushResult> r = pushCmd.call();
+    return Iterables.getOnlyElement(r);
+  }
+
   public static Optional<String> getChangeId(TestRepository<?> tr, ObjectId id)
       throws IOException {
     RevCommit c = tr.getRevWalk().parseCommit(id);
     tr.getRevWalk().parseBody(c);
-    List<String> ids = c.getFooterLines(FooterConstants.CHANGE_ID);
-    if (ids.isEmpty()) {
-      return Optional.absent();
-    }
-    return Optional.of(ids.get(ids.size() - 1));
+    return Lists.reverse(c.getFooterLines(FooterConstants.CHANGE_ID)).stream()
+        .findFirst();
   }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpResponse.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpResponse.java
index 390cae3..e9c6e96 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpResponse.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpResponse.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Preconditions;
 
+import org.apache.http.Header;
 import org.eclipse.jgit.util.IO;
 import org.eclipse.jgit.util.RawParseUtils;
 
@@ -52,7 +53,12 @@
   }
 
   public String getContentType() {
-    return response.getFirstHeader("X-FYI-Content-Type").getValue();
+    return getHeader("X-FYI-Content-Type");
+  }
+
+  public String getHeader(String name) {
+    Header hdr = response.getFirstHeader(name);
+    return hdr != null ? hdr.getValue() : null;
   }
 
   public boolean hasContent() {
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java
index 1e0920e..e5182df 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import com.google.common.base.CharMatcher;
+import com.google.gerrit.common.Nullable;
 
 import org.apache.http.HttpHost;
 import org.apache.http.client.fluent.Executor;
@@ -24,20 +25,27 @@
 import java.net.URI;
 
 public class HttpSession {
-
+  protected TestAccount account;
   protected final String url;
   private final Executor executor;
 
-  public HttpSession(GerritServer server, TestAccount account) {
+  public HttpSession(GerritServer server, @Nullable TestAccount account) {
     this.url = CharMatcher.is('/').trimTrailingFrom(server.getUrl());
     URI uri = URI.create(url);
-    this.executor = Executor
-        .newInstance()
-        .auth(new HttpHost(uri.getHost(), uri.getPort()),
+    this.executor = Executor.newInstance();
+    this.account = account;
+    if (account != null) {
+        executor.auth(
+            new HttpHost(uri.getHost(), uri.getPort()),
             account.username, account.httpPassword);
+    }
   }
 
-  protected RestResponse execute(Request request) throws IOException {
+  public String url() {
+    return url;
+  }
+
+  public RestResponse execute(Request request) throws IOException {
     return new RestResponse(executor.execute(request).returnResponse());
   }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index f53202f..0783688 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -27,6 +27,8 @@
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
@@ -89,6 +91,7 @@
     bind(Key.get(schemaFactory, ReviewDbFactory.class))
         .to(InMemoryDatabase.class);
     bind(InMemoryDatabase.class).in(SINGLETON);
+    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
 
     listener().to(CreateDatabase.class);
 
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
index d79e573..1b61d1b 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static org.junit.Assert.assertEquals;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
@@ -51,15 +52,7 @@
   public static final String SUBJECT = "test commit";
   public static final String FILE_NAME = "a.txt";
   public static final String FILE_CONTENT = "some content";
-  public static final String PATCH =
-      "From %s Mon Sep 17 00:00:00 2001\n" +
-      "From: Administrator <admin@example.com>\n" +
-      "Date: %s\n" +
-      "Subject: [PATCH] test commit\n" +
-      "\n" +
-      "Change-Id: %s\n" +
-      "---\n" +
-      "\n" +
+  public static final String PATCH_FILE_ONLY =
       "diff --git a/a.txt b/a.txt\n" +
       "new file mode 100644\n" +
       "index 0000000..f0eec86\n" +
@@ -68,6 +61,15 @@
       "@@ -0,0 +1 @@\n" +
       "+some content\n" +
       "\\ No newline at end of file\n";
+  public static final String PATCH =
+      "From %s Mon Sep 17 00:00:00 2001\n" +
+      "From: Administrator <admin@example.com>\n" +
+      "Date: %s\n" +
+      "Subject: [PATCH] test commit\n" +
+      "\n" +
+      "Change-Id: %s\n" +
+      "---\n" +
+      "\n" + PATCH_FILE_ONLY;
 
   public interface Factory {
     PushOneCommit create(
@@ -136,6 +138,7 @@
   private String changeId;
   private Tag tag;
   private boolean force;
+  private List<String> pushOptions;
 
   private final TestRepository<?>.CommitBuilder commitBuilder;
 
@@ -275,8 +278,8 @@
       }
       tagCommand.call();
     }
-    return new Result(ref, pushHead(testRepo, ref, tag != null, force), c,
-        subject);
+    return new Result(ref,
+        pushHead(testRepo, ref, tag != null, force, pushOptions), c, subject);
   }
 
   public void setTag(final Tag tag) {
@@ -287,6 +290,14 @@
     this.force = force;
   }
 
+  public List<String> getPushOptions() {
+    return pushOptions;
+  }
+
+  public void setPushOptions(List<String> pushOptions) {
+    this.pushOptions = pushOptions;
+  }
+
   public void noParents() {
     commitBuilder.noParents();
   }
@@ -326,6 +337,10 @@
       return commit;
     }
 
+    public void assertPushOptions(List<String> pushOptions) {
+      assertEquals(pushOptions, getPushOptions());
+    }
+
     public void assertChange(Change.Status expectedStatus,
         String expectedTopic, TestAccount... expectedReviewers)
         throws OrmException, NoSuchChangeException {
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
new file mode 100644
index 0000000..cdecf05
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.reviewdb.client.Change.Id;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+
+import java.io.IOException;
+
+public class ReadOnlyChangeIndex implements ChangeIndex {
+  private final ChangeIndex index;
+
+  public ReadOnlyChangeIndex(ChangeIndex index) {
+    this.index = index;
+  }
+
+  public ChangeIndex unwrap() {
+    return index;
+  }
+
+  @Override
+  public Schema<ChangeData> getSchema() {
+    return index.getSchema();
+  }
+
+  @Override
+  public void close() {
+    index.close();
+  }
+
+  @Override
+  public void replace(ChangeData obj) throws IOException {
+    // do nothing
+  }
+
+  @Override
+  public void delete(Id key) throws IOException {
+    // do nothing
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+    // do nothing
+  }
+
+  @Override
+  public DataSource<ChangeData> getSource(Predicate<ChangeData> p,
+      QueryOptions opts) throws QueryParseException {
+    return index.getSource(p, opts);
+  }
+
+  @Override
+  public void markReady(boolean ready) throws IOException {
+    // do nothing
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
index 9c59e10..689b2d0 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.net.HttpHeaders;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.server.OutputFormat;
 
@@ -32,7 +33,7 @@
 
 public class RestSession extends HttpSession {
 
-  public RestSession(GerritServer server, TestAccount account) {
+  public RestSession(GerritServer server, @Nullable TestAccount account) {
     super(server, account);
   }
 
@@ -45,9 +46,9 @@
         new BasicHeader(HttpHeaders.ACCEPT, "application/json"));
   }
 
-  private RestResponse getWithHeader(String endPoint, Header header)
+  public RestResponse getWithHeader(String endPoint, Header header)
       throws IOException {
-    Request get = Request.Get(url + "/a" + endPoint);
+    Request get = Request.Get(getUrl(endPoint));
     if (header != null) {
       get.addHeader(header);
     }
@@ -55,7 +56,7 @@
   }
 
   public RestResponse head(String endPoint) throws IOException {
-    return execute(Request.Head(url + "/a" + endPoint));
+    return execute(Request.Head(getUrl(endPoint)));
   }
 
   public RestResponse put(String endPoint) throws IOException {
@@ -73,7 +74,7 @@
 
   public RestResponse putWithHeader(String endPoint, Header header,
       Object content) throws IOException {
-    Request put = Request.Put(url + "/a" + endPoint);
+    Request put = Request.Put(getUrl(endPoint));
     if (header != null) {
       put.addHeader(header);
     }
@@ -88,7 +89,7 @@
 
   public RestResponse putRaw(String endPoint, RawInput stream) throws IOException {
     Preconditions.checkNotNull(stream);
-    Request put = Request.Put(url + "/a" + endPoint);
+    Request put = Request.Put(getUrl(endPoint));
     put.addHeader(new BasicHeader("Content-Type", stream.getContentType()));
     put.body(new BufferedHttpEntity(
         new InputStreamEntity(
@@ -102,7 +103,15 @@
   }
 
   public RestResponse post(String endPoint, Object content) throws IOException {
-    Request post = Request.Post(url + "/a" + endPoint);
+    return postWithHeader(endPoint, content, null);
+  }
+
+  public RestResponse postWithHeader(String endPoint, Object content,
+      Header header) throws IOException {
+    Request post = Request.Post(getUrl(endPoint));
+    if (header != null) {
+      post.addHeader(header);
+    }
     if (content != null) {
       post.addHeader(new BasicHeader("Content-Type", "application/json"));
       post.body(new StringEntity(
@@ -113,6 +122,10 @@
   }
 
   public RestResponse delete(String endPoint) throws IOException {
-    return execute(Request.Delete(url + "/a" + endPoint));
+    return execute(Request.Delete(getUrl(endPoint)));
+  }
+
+  private String getUrl(String endPoint) {
+    return url + (account != null ? "/a" : "") + endPoint;
   }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/Sandboxed.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/Sandboxed.java
new file mode 100644
index 0000000..11446e0
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/Sandboxed.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+public @interface Sandboxed {
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
index 7f08b6f..63e0fa7 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.acceptance;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.mail.Address;
 
@@ -25,34 +25,22 @@
 
 import java.io.ByteArrayOutputStream;
 import java.util.Arrays;
+import java.util.List;
 
 public class TestAccount {
-  public static FluentIterable<Account.Id> ids(
-      Iterable<TestAccount> accounts) {
-    return FluentIterable.from(accounts)
-        .transform(new Function<TestAccount, Account.Id>() {
-          @Override
-          public Account.Id apply(TestAccount in) {
-            return in.id;
-          }
-        });
+  public static List<Account.Id> ids(List<TestAccount> accounts) {
+    return accounts.stream().map(a -> a.id).collect(toList());
   }
 
-  public static FluentIterable<Account.Id> ids(TestAccount... accounts) {
+  public static List<Account.Id> ids(TestAccount... accounts) {
     return ids(Arrays.asList(accounts));
   }
 
-  public static FluentIterable<String> names(Iterable<TestAccount> accounts) {
-    return FluentIterable.from(accounts)
-        .transform(new Function<TestAccount, String>() {
-          @Override
-          public String apply(TestAccount in) {
-            return in.fullName;
-          }
-        });
+  public static List<String> names(List<TestAccount> accounts) {
+    return accounts.stream().map(a -> a.fullName).collect(toList());
   }
 
-  public static FluentIterable<String> names(TestAccount... accounts) {
+  public static List<String> names(TestAccount... accounts) {
     return names(Arrays.asList(accounts));
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/SandboxTest.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/SandboxTest.java
new file mode 100644
index 0000000..3ff7112
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/SandboxTest.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.After;
+import org.junit.Test;
+
+@Sandboxed
+public class SandboxTest extends AbstractDaemonTest {
+  @After
+  public void addUser() throws Exception {
+    gApi.accounts().create("sandboxuser");
+  }
+
+  @Test
+  public void testUserNotPresent1() throws Exception {
+    assertThat(gApi.accounts().query("sandboxuser").get()).isEmpty();
+  }
+
+  @Test
+  public void testUserNotPresent2() throws Exception {
+    assertThat(gApi.accounts().query("sandboxuser").get()).isEmpty();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/UseGerritConfigAnnotationTest.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/UseGerritConfigAnnotationTest.java
index 2f50480..5773da4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/UseGerritConfigAnnotationTest.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/UseGerritConfigAnnotationTest.java
@@ -35,10 +35,8 @@
   }
 
   @Test
-  @GerritConfigs({
-      @GerritConfig(name = "x.y", value = "z"),
-      @GerritConfig(name = "a.b", value = "c")
-  })
+  @GerritConfig(name = "x.y", value = "z")
+  @GerritConfig(name = "a.b", value = "c")
   public void testMultiple() {
     assertThat(serverConfig.getString("x", null, "y")).isEqualTo("z");
     assertThat(serverConfig.getString("a", null, "b")).isEqualTo("c");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 93525a4..01009aa 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -29,8 +29,8 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -199,6 +199,36 @@
   }
 
   @Test
+  public void active() throws Exception {
+    assertThat(gApi.accounts().id("user").getActive()).isTrue();
+    gApi.accounts().id("user").setActive(false);
+    assertThat(gApi.accounts().id("user").getActive()).isFalse();
+    gApi.accounts().id("user").setActive(true);
+    assertThat(gApi.accounts().id("user").getActive()).isTrue();
+  }
+
+  @Test
+  public void deactivateSelf() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("cannot deactivate own account");
+    gApi.accounts().self().setActive(false);
+  }
+
+  @Test
+  public void deactivateNotActive() throws Exception {
+    assertThat(gApi.accounts().id("user").getActive()).isTrue();
+    gApi.accounts().id("user").setActive(false);
+    assertThat(gApi.accounts().id("user").getActive()).isFalse();
+    try {
+      gApi.accounts().id("user").setActive(false);
+      fail("Expected exception");
+    } catch (ResourceConflictException e) {
+      assertThat(e.getMessage()).isEqualTo("account not active");
+    }
+    gApi.accounts().id("user").setActive(true);
+  }
+
+  @Test
   public void starUnstarChange() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -722,13 +752,7 @@
     Map<String, GpgKeyInfo> keyMap = gApi.accounts().self().listGpgKeys();
     assertThat(keyMap.keySet())
         .named("keys returned by listGpgKeys()")
-        .containsExactlyElementsIn(
-          expected.transform(new Function<TestKey, String>() {
-            @Override
-            public String apply(TestKey in) {
-              return in.getKeyIdString();
-            }
-          }));
+        .containsExactlyElementsIn(expected.transform(TestKey::getKeyIdString));
 
     for (TestKey key : expected) {
       assertKeyEquals(key, gApi.accounts().self().gpgKey(
@@ -740,23 +764,13 @@
 
     // Check raw external IDs.
     Account.Id currAccountId = atrScope.get().getUser().getAccountId();
-    assertThat(
-        GpgKeys.getGpgExtIds(db, currAccountId)
-          .transform(new Function<AccountExternalId, String>() {
-            @Override
-            public String apply(AccountExternalId in) {
-              return in.getSchemeRest();
-            }
-          }))
+    Iterable<String> expectedFps = expected.transform(
+        k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
+    Iterable<String> actualFps = GpgKeys.getGpgExtIds(db, currAccountId)
+        .transform(AccountExternalId::getSchemeRest);
+    assertThat(actualFps)
         .named("external IDs in database")
-        .containsExactlyElementsIn(
-            expected.transform(new Function<TestKey, String>() {
-              @Override
-              public String apply(TestKey in) {
-                return BaseEncoding.base16().encode(
-                    in.getPublicKey().getFingerprint());
-              }
-            }));
+        .containsExactlyElementsIn(expectedFps);
 
     // Check raw stored keys.
     for (TestKey key : expected) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 8cd696c..00b48b4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -19,27 +19,22 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.junit.Assert.fail;
 
-import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.common.AgreementInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestTimeUtil;
 
@@ -52,8 +47,8 @@
 import java.util.List;
 
 public class AgreementsIT extends AbstractDaemonTest {
-  private ContributorAgreement ca;
-  private ContributorAgreement ca2;
+  private ContributorAgreement caAutoVerify;
+  private ContributorAgreement caNoAutoVerify;
 
   @ConfigSuite.Config
   public static Config enableAgreementsConfig() {
@@ -74,32 +69,26 @@
 
   @Before
   public void setUp() throws Exception {
-    String g = createGroup("cla-test-group");
-    GroupApi groupApi = gApi.groups().id(g);
-    groupApi.description("CLA test group");
-    AccountGroup caGroup = groupCache.get(
-        new AccountGroup.UUID(groupApi.detail().id));
-    GroupReference groupRef = GroupReference.forGroup(caGroup);
-    PermissionRule rule = new PermissionRule(groupRef);
-    rule.setAction(PermissionRule.Action.ALLOW);
-    ca = new ContributorAgreement("cla-test");
-    ca.setDescription("description");
-    ca.setAgreementUrl("agreement-url");
-    ca.setAutoVerify(groupRef);
-    ca.setAccepted(ImmutableList.of(rule));
-
-    ca2 = new ContributorAgreement("cla-test-no-auto-verify");
-    ca2.setDescription("description");
-    ca2.setAgreementUrl("agreement-url");
-
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    cfg.replace(ca);
-    cfg.replace(ca2);
-    saveProjectConfig(allProjects, cfg);
+    caAutoVerify = configureContributorAgreement(true);
+    caNoAutoVerify = configureContributorAgreement(false);
     setApiUser(user);
   }
 
   @Test
+  public void getAvailableAgreements() throws Exception {
+    ServerInfo info = gApi.config().server().getInfo();
+    if (isContributorAgreementsEnabled()) {
+      assertThat(info.auth.useContributorAgreements).isTrue();
+      assertThat(info.auth.contributorAgreements).hasSize(2);
+      assertAgreement(info.auth.contributorAgreements.get(0), caAutoVerify);
+      assertAgreement(info.auth.contributorAgreements.get(1), caNoAutoVerify);
+    } else {
+      assertThat(info.auth.useContributorAgreements).isNull();
+      assertThat(info.auth.contributorAgreements).isNull();
+    }
+  }
+
+  @Test
   public void signNonExistingAgreement() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
     exception.expect(UnprocessableEntityException.class);
@@ -112,7 +101,7 @@
     assume().that(isContributorAgreementsEnabled()).isTrue();
     exception.expect(BadRequestException.class);
     exception.expectMessage("cannot enter a non-autoVerify agreement");
-    gApi.accounts().self().signAgreement(ca2.getName());
+    gApi.accounts().self().signAgreement(caNoAutoVerify.getName());
   }
 
   @Test
@@ -124,7 +113,7 @@
     assertThat(result).isEmpty();
 
     // Sign the agreement
-    gApi.accounts().self().signAgreement(ca.getName());
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
 
     // Explicitly reset the user to force a new request context
     setApiUser(user);
@@ -133,12 +122,10 @@
     result = gApi.accounts().self().listAgreements();
     assertThat(result).hasSize(1);
     AgreementInfo info = result.get(0);
-    assertThat(info.name).isEqualTo(ca.getName());
-    assertThat(info.description).isEqualTo(ca.getDescription());
-    assertThat(info.url).isEqualTo(ca.getAgreementUrl());
+    assertAgreement(info, caAutoVerify);
 
     // Signing the same agreement again has no effect
-    gApi.accounts().self().signAgreement(ca.getName());
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
     result = gApi.accounts().self().listAgreements();
     assertThat(result).hasSize(1);
   }
@@ -148,7 +135,7 @@
     assume().that(isContributorAgreementsEnabled()).isFalse();
     exception.expect(MethodNotAllowedException.class);
     exception.expectMessage("contributor agreements disabled");
-    gApi.accounts().self().signAgreement(ca.getName());
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
   }
 
   @Test
@@ -227,7 +214,7 @@
     }
 
     // Sign the agreement
-    gApi.accounts().self().signAgreement(ca.getName());
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
 
     // Explicitly reset the user to force a new request context
     setApiUser(user);
@@ -236,6 +223,18 @@
     gApi.changes().create(newChangeInput());
   }
 
+  private void assertAgreement(AgreementInfo info, ContributorAgreement ca) {
+    assertThat(info.name).isEqualTo(ca.getName());
+    assertThat(info.description).isEqualTo(ca.getDescription());
+    assertThat(info.url).isEqualTo(ca.getAgreementUrl());
+    if (ca.getAutoVerify() != null) {
+      assertThat(info.autoVerifyGroup.name)
+          .isEqualTo(ca.getAutoVerify().getName());
+    } else {
+      assertThat(info.autoVerifyGroup).isNull();
+    }
+  }
+
   private ChangeInput newChangeInput() {
     ChangeInput in = new ChangeInput();
     in.branch = "master";
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
index 9236176..bce9861 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
@@ -78,6 +78,7 @@
     // change all default values
     i.context *= -1;
     i.tabSize *= -1;
+    i.fontSize *= -1;
     i.lineLength *= -1;
     i.cursorBlinkRate = 500;
     i.theme = Theme.MIDNIGHT;
@@ -121,9 +122,11 @@
     DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
     int newLineLength = d.lineLength + 10;
     int newTabSize = d.tabSize * 2;
+    int newFontSize = d.fontSize - 2;
     DiffPreferencesInfo update = new DiffPreferencesInfo();
     update.lineLength = newLineLength;
     update.tabSize = newTabSize;
+    update.fontSize = newFontSize;
     gApi.config().server().setDefaultDiffPreferences(update);
 
     DiffPreferencesInfo o = gApi.accounts()
@@ -133,8 +136,9 @@
     // assert configured defaults
     assertThat(o.lineLength).isEqualTo(newLineLength);
     assertThat(o.tabSize).isEqualTo(newTabSize);
+    assertThat(o.fontSize).isEqualTo(newFontSize);
 
     // assert hard-coded defaults
-    assertPrefs(o, d, "lineLength", "tabSize");
+    assertPrefs(o, d, "lineLength", "tabSize", "fontSize");
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index f45bfbbe..1a43784 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
@@ -87,6 +88,8 @@
     i.dateFormat = DateFormat.US;
     i.timeFormat = TimeFormat.HHMM_24;
     i.emailStrategy = EmailStrategy.DISABLED;
+    i.defaultBaseForMerges = DefaultBase.AUTO_MERGE;
+    i.highlightAssigneeInChangeTable ^= true;
     i.relativeDateInChangeTable ^= true;
     i.sizeBarInChangeTable ^= true;
     i.legacycidInChangeTable ^= true;
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 006d8a4..479e34b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -30,20 +30,22 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.GerritConfigs;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
@@ -58,28 +60,37 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.inject.Inject;
 
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -96,6 +107,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.EnumSet;
 import java.util.Iterator;
 import java.util.List;
@@ -105,6 +117,9 @@
 public class ChangeIT extends AbstractDaemonTest {
   private String systemTimeZone;
 
+  @Inject
+  private BatchUpdate.Factory updateFactory;
+
   @Before
   public void setTimeForTesting() {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
@@ -184,6 +199,64 @@
   }
 
   @Test
+  public void batchAbandon() throws Exception {
+    CurrentUser user = atrScope.get().getUser();
+    PushOneCommit.Result a = createChange();
+    List<ChangeControl> controlA = changeFinder.find(a.getChangeId(), user);
+    assertThat(controlA).hasSize(1);
+    PushOneCommit.Result b = createChange();
+    List<ChangeControl> controlB = changeFinder.find(b.getChangeId(), user);
+    assertThat(controlB).hasSize(1);
+    List<ChangeControl> list =
+        ImmutableList.of(controlA.get(0), controlB.get(0));
+    changeAbandoner.batchAbandon(
+        controlA.get(0).getProject().getNameKey(), user, list, "deadbeef");
+
+    ChangeInfo info = get(a.getChangeId());
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase())
+        .contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase())
+        .contains("deadbeef");
+
+    info = get(b.getChangeId());
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase())
+        .contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase())
+        .contains("deadbeef");
+  }
+
+  @Test
+  public void batchAbandonChangeProject() throws Exception {
+    String project1Name = name("Project1");
+    String project2Name = name("Project2");
+    gApi.projects().create(project1Name);
+    gApi.projects().create(project2Name);
+    TestRepository<InMemoryRepository> project1 =
+        cloneProject(new Project.NameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 =
+        cloneProject(new Project.NameKey(project2Name));
+
+    CurrentUser user = atrScope.get().getUser();
+    PushOneCommit.Result a =
+        createChange(project1, "master", "x", "x", "x", "");
+    List<ChangeControl> controlA = changeFinder.find(a.getChangeId(), user);
+    assertThat(controlA).hasSize(1);
+    PushOneCommit.Result b =
+        createChange(project2, "master", "x", "x", "x", "");
+    List<ChangeControl> controlB = changeFinder.find(b.getChangeId(), user);
+    assertThat(controlB).hasSize(1);
+    List<ChangeControl> list =
+        ImmutableList.of(controlA.get(0), controlB.get(0));
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(String.format(
+        "Project name \"%s\" doesn't match \"%s\"",
+        project2Name, project1Name));
+    changeAbandoner.batchAbandon(new Project.NameKey(project1Name), user, list);
+  }
+
+  @Test
   public void abandonDraft() throws Exception {
     PushOneCommit.Result r = createDraftChange();
     String changeId = r.getChangeId();
@@ -330,7 +403,7 @@
   }
 
   @Test
-  public void delete() throws Exception {
+  public void deleteDraftChange() throws Exception {
     PushOneCommit.Result r = createChange("refs/drafts/master");
     assertThat(query(r.getChangeId())).hasSize(1);
     assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.DRAFT);
@@ -341,43 +414,137 @@
   }
 
   @Test
-  public void voteOnClosedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    merge(r);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is closed");
-    revision(r).review(ReviewInput.reject());
+  public void deleteNewChangeAsAdmin() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+
+    gApi.changes()
+        .id(changeId)
+        .delete();
+
+    assertThat(query(changeId)).isEmpty();
   }
 
   @Test
-  public void voteOnBehalfOf() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType codeReviewType = Util.codeReview();
-    String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
-    String heads = "refs/heads/*";
-    AccountGroup.UUID owner =
-        SystemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
-    Util.allow(cfg, forCodeReviewAs, -1, 1, owner, heads);
-    saveProjectConfig(project, cfg);
+  @TestProjectInput(cloneAs = "user")
+  public void deleteNewChangeAsNormalUser() throws Exception {
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo)
+            .to("refs/for/master");
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
 
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes()
-        .id(r.getChangeId())
-        .current();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage(String.format(
+        "Deleting change %s is not permitted", id));
+    gApi.changes()
+        .id(changeId)
+        .delete();
+  }
 
-    ReviewInput in = ReviewInput.recommend();
-    in.onBehalfOf = user.id.toString();
-    revision.review(in);
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteNewChangeOfAnotherUserAsAdmin() throws Exception {
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo)
+            .to("refs/for/master");
+    changeResult.assertOkStatus();
+    String changeId = changeResult.getChangeId();
 
-    ChangeInfo c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
+    setApiUser(admin);
+    gApi.changes()
+        .id(changeId)
+        .delete();
 
-    LabelInfo codeReview = c.labels.get("Code-Review");
-    assertThat(codeReview.all).hasSize(1);
-    ApprovalInfo approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
-    assertThat(approval.value).isEqualTo(1);
+    assertThat(query(changeId)).isEmpty();
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void deleteNewChangeForBranchWithoutCommits() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+
+    gApi.changes()
+        .id(changeId)
+        .delete();
+
+    assertThat(query(changeId)).isEmpty();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteAbandonedChangeAsNormalUser() throws Exception {
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo)
+        .to("refs/for/master");
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    setApiUser(user);
+    gApi.changes()
+        .id(changeId)
+        .abandon();
+
+    exception.expect(AuthException.class);
+    exception.expectMessage(String.format(
+        "Deleting change %s is not permitted", id));
+    gApi.changes()
+        .id(changeId)
+        .delete();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteAbandonedChangeOfAnotherUserAsAdmin() throws Exception {
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo)
+        .to("refs/for/master");
+    String changeId = changeResult.getChangeId();
+
+    gApi.changes()
+        .id(changeId)
+        .abandon();
+
+    gApi.changes()
+        .id(changeId)
+        .delete();
+
+    assertThat(query(changeId)).isEmpty();
+  }
+
+  @Test
+  public void deleteMergedChange() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    merge(changeResult);
+
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage(String.format(
+        "Deleting merged change %s is not allowed", id));
+    gApi.changes()
+        .id(changeId)
+        .delete();
+  }
+
+  @Test
+  public void deleteNewChangeWithMergedPatchSet() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    merge(changeResult);
+    setChangeStatus(id, Change.Status.NEW);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(String.format(
+        "Cannot delete change %s: patch set 1 is already merged", id));
+    gApi.changes()
+        .id(changeId)
+        .delete();
   }
 
   @Test
@@ -538,6 +705,149 @@
   }
 
   @Test
+  public void pushCommitOfOtherUser() throws Exception {
+    // admin pushes commit of user
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), testRepo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+    CommitInfo commit = change.revisions.get(change.currentRevision).commit;
+    assertThat(commit.author.email).isEqualTo(user.email);
+    assertThat(commit.committer.email).isEqualTo(user.email);
+
+    // check that the author/committer was added as reviewer
+    Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId)
+        .isEqualTo(user.getId().get());
+    assertThat(change.reviewers.get(CC)).isNull();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body())
+        .contains(admin.fullName + " has uploaded a new change for review");
+    assertThat(m.body())
+        .contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertMailFrom(m, admin.email);
+  }
+
+  @Test
+  public void pushCommitOfOtherUserThatCannotSeeChange() throws Exception {
+    // create hidden project that is only visible to administrators
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.allow(cfg,
+        Permission.READ,
+        groupCache.get(new AccountGroup.NameKey("Administrators"))
+            .getGroupUUID(),
+        "refs/*");
+    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    // admin pushes commit of user
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+    CommitInfo commit = change.revisions.get(change.currentRevision).commit;
+    assertThat(commit.author.email).isEqualTo(user.email);
+    assertThat(commit.committer.email).isEqualTo(user.email);
+
+    // check the user cannot see the change
+    setApiUser(user);
+    try {
+      gApi.changes().id(result.getChangeId()).get();
+      fail("Expected ResourceNotFoundException");
+    } catch (ResourceNotFoundException e) {
+      // Expected.
+    }
+
+    // check that the author/committer was NOT added as reviewer (he can't see
+    // the change)
+    assertThat(change.reviewers.get(REVIEWER)).isNull();
+    assertThat(change.reviewers.get(CC)).isNull();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void pushCommitWithFooterOfOtherUser() throws Exception {
+    // admin pushes commit that references 'user' in a footer
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
+        PushOneCommit.SUBJECT + "\n\n"
+            + FooterConstants.REVIEWED_BY.getName() + ": "
+            + user.getIdent().toExternalString(),
+        PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    // check that 'user' was added as reviewer
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId)
+        .isEqualTo(user.getId().get());
+    assertThat(change.reviewers.get(CC)).isNull();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+    assertThat(m.body()).contains("I'd like you to do a code review.");
+    assertThat(m.body())
+        .contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertMailFrom(m, admin.email);
+  }
+
+  @Test
+  public void pushCommitWithFooterOfOtherUserThatCannotSeeChange()
+      throws Exception {
+    // create hidden project that is only visible to administrators
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.allow(cfg,
+        Permission.READ, groupCache
+            .get(new AccountGroup.NameKey("Administrators")).getGroupUUID(),
+        "refs/*");
+    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    // admin pushes commit that references 'user' in a footer
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo,
+        PushOneCommit.SUBJECT + "\n\n" + FooterConstants.REVIEWED_BY.getName()
+            + ": " + user.getIdent().toExternalString(),
+        PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    // check that 'user' cannot see the change
+    setApiUser(user);
+    try {
+      gApi.changes().id(result.getChangeId()).get();
+      fail("Expected ResourceNotFoundException");
+    } catch (ResourceNotFoundException e) {
+      // Expected.
+    }
+
+    // check that 'user' was NOT added as cc ('user' can't see the change)
+    setApiUser(admin);
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.reviewers.get(REVIEWER)).isNull();
+    assertThat(change.reviewers.get(CC)).isNull();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void addReviewerThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
     Project.NameKey p = createProject("p");
@@ -577,6 +887,22 @@
   }
 
   @Test
+  public void addReviewerThatIsInactive() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    String username = name("new-user");
+    gApi.accounts().create(username).setActive(false);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = username;
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account of " + username + " is inactive.");
+    gApi.changes()
+        .id(r.getChangeId())
+        .addReviewer(in);
+  }
+
+  @Test
   public void addReviewer() throws Exception {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
     PushOneCommit.Result r = createChange();
@@ -619,6 +945,26 @@
   }
 
   @Test
+  public void addReviewerWithNoteDbWhenDummyApprovalInReviewDbExists()
+      throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+
+    // insert dummy approval in ReviewDb
+    PatchSetApproval psa =
+        new PatchSetApproval(new PatchSetApproval.Key(r.getPatchSetId(),
+            user.id, new LabelId("Code-Review")), (short) 0, TimeUtil.nowTs());
+    db.patchSetApprovals().insert(Collections.singleton(psa));
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes()
+        .id(r.getChangeId())
+        .addReviewer(in);
+  }
+
+  @Test
   public void addSelfAsReviewer() throws Exception {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
     PushOneCommit.Result r = createChange();
@@ -755,11 +1101,18 @@
     assertThat(reviewers.iterator().next()._accountId)
         .isEqualTo(user.getId().get());
 
+    sender.clear();
     gApi.changes()
         .id(changeId)
         .reviewer(user.getId().toString())
         .remove();
-    assertThat(gApi.changes().id(changeId).get().reviewers.isEmpty());
+    assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = sender.getMessages().get(0);
+    assertThat(message.body()).contains(
+        "Removed reviewer " + user.fullName + ".");
+    assertThat(message.body()).doesNotContain("with the following votes");
 
     // Make sure the reviewer can still be added again.
     gApi.changes()
@@ -785,6 +1138,15 @@
 
   @Test
   public void removeReviewer() throws Exception {
+    testRemoveReviewer(true);
+  }
+
+  @Test
+  public void removeNoNotify() throws Exception {
+    testRemoveReviewer(false);
+  }
+
+  private void testRemoveReviewer(boolean notify) throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     gApi.changes()
@@ -810,11 +1172,26 @@
     assertThat(reviewerIt.next()._accountId)
         .isEqualTo(user.getId().get());
 
+    sender.clear();
     setApiUser(admin);
+    DeleteReviewerInput input = new DeleteReviewerInput();
+    if (!notify) {
+      input.notify = NotifyHandling.NONE;
+    }
     gApi.changes()
         .id(changeId)
         .reviewer(user.getId().toString())
-        .remove();
+        .remove(input);
+
+    if (notify) {
+      assertThat(sender.getMessages()).hasSize(1);
+      Message message = sender.getMessages().get(0);
+      assertThat(message.body()).contains(
+          "Removed reviewer " + user.fullName + " with the following votes");
+      assertThat(message.body()).contains("* Code-Review+1 by " + user.fullName);
+    } else {
+      assertThat(sender.getMessages()).hasSize(0);
+    }
 
     reviewers = gApi.changes()
         .id(changeId)
@@ -882,18 +1259,8 @@
         .reviewer(user.getId().toString())
         .votes();
 
-    if (NoteDbMode.readWrite()) {
-      // When NoteDb is enabled each reviewer is explicitly recorded in the
-      // NoteDb and this record stays even when all votes of that user have been
-      // deleted, hence there is no dummy 0 approval left when a vote is
-      // deleted.
-      assertThat(m).isEmpty();
-    } else {
-      // When NoteDb is disabled there is a dummy 0 approval on the change so
-      // that the user is still returned as CC when all votes of that user have
-      // been deleted.
-      assertThat(m).containsEntry("Code-Review", Short.valueOf((short)0));
-    }
+    // Dummy 0 approval on the change to block vote copying to this patch set.
+    assertThat(m).containsExactly("Code-Review", Short.valueOf((short)0));
 
     ChangeInfo c = gApi.changes()
         .id(r.getChangeId())
@@ -1205,6 +1572,31 @@
   }
 
   @Test
+  public void submitStaleChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    disableChangeIndexWrites();
+    try {
+      r = amendChange(r.getChangeId());
+    } finally {
+      enableChangeIndexWrites();
+    }
+
+    gApi.changes()
+      .id(r.getChangeId())
+      .current()
+      .review(ReviewInput.approve());
+
+    gApi.changes()
+      .id(r.getChangeId())
+      .current()
+      .submit();
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .info().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
   public void check() throws Exception {
     // TODO(dborowitz): Re-enable when ConsistencyChecker supports NoteDb.
     assume().that(notesMigration.enabled()).isFalse();
@@ -1341,10 +1733,8 @@
   }
 
   @Test
-  @GerritConfigs({
-    @GerritConfig(name = "gerrit.editGpgKeys", value = "true"),
-    @GerritConfig(name = "receive.enableSignedPush", value = "true"),
-  })
+  @GerritConfig(name = "gerrit.editGpgKeys", value = "true")
+  @GerritConfig(name = "receive.enableSignedPush", value = "true")
   public void pushCertificates() throws Exception {
     PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 = amendChange(r1.getChangeId());
@@ -1671,14 +2061,256 @@
         + r1.getChange().getId().id + ".");
   }
 
+  @Test
+  public void testCreateMergePatchSet() throws Exception {
+    PushOneCommit.Result start = pushTo("refs/heads/master");
+    start.assertOkStatus();
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    String changeId = r.getChangeId();
+
+    testRepo.reset(start.getCommit());
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    createBranch(new Branch.NameKey(project, "dev"));
+    PushOneCommit.Result changeA = pushFactory
+        .create(db, user.getIdent(), testRepo, "change A", "A.txt", "A content")
+        .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo = gApi.changes().id(changeId)
+        .get(EnumSet.of(ListChangesOption.ALL_REVISIONS,
+            ListChangesOption.CURRENT_COMMIT,
+            ListChangesOption.CURRENT_REVISION));
+    assertThat(changeInfo.revisions.size()).isEqualTo(2);
+    assertThat(changeInfo.subject).isEqualTo(in.subject);
+    assertThat(
+        changeInfo.revisions.get(changeInfo.currentRevision).commit.parents
+            .get(0).commit).isEqualTo(parent);
+  }
+
+  @Test
+  public void testCreateMergePatchSetInheritParent() throws Exception {
+    PushOneCommit.Result start = pushTo("refs/heads/master");
+    start.assertOkStatus();
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    String changeId = r.getChangeId();
+    String parent = r.getCommit().getParent(0).getName();
+
+    // advance master branch
+    testRepo.reset(start.getCommit());
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    createBranch(new Branch.NameKey(project, "dev"));
+    PushOneCommit.Result changeA = pushFactory
+        .create(db, user.getIdent(), testRepo, "change A", "A.txt", "A content")
+        .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2 inherit parent of ps1";
+    in.inheritParent = true;
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo = gApi.changes().id(changeId)
+        .get(EnumSet.of(ListChangesOption.ALL_REVISIONS,
+            ListChangesOption.CURRENT_COMMIT,
+            ListChangesOption.CURRENT_REVISION));
+
+    assertThat(changeInfo.revisions.size()).isEqualTo(2);
+    assertThat(changeInfo.subject).isEqualTo(in.subject);
+    assertThat(
+        changeInfo.revisions.get(changeInfo.currentRevision).commit.parents
+            .get(0).commit).isEqualTo(parent);
+    assertThat(
+        changeInfo.revisions.get(changeInfo.currentRevision).commit.parents
+            .get(0).commit).isNotEqualTo(currentMaster.getCommit().getName());
+  }
+
+  @Test
+  public void checkLabelsForOpenChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeInfo change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+
+    // add new label and assert that it's returned for existing changes
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType verified = Util.verified();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    AccountGroup.UUID registeredUsers =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1,
+        registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.labels.keySet())
+        .containsExactly("Code-Review", "Verified");
+    assertThat(change.permittedLabels.keySet())
+        .containsExactly("Code-Review", "Verified");
+
+    // add an approval on the new label
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(new ReviewInput().label(
+            verified.getName(), verified.getMax().getValue()));
+
+    // remove label and assert that it's no longer returned for existing
+    // changes, even if there is an approval for it
+    cfg.getLabelSections().remove(verified.getName());
+    Util.remove(cfg, Permission.forLabel(verified.getName()), registeredUsers,
+        heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+  }
+
+  @Test
+  public void checkLabelsForMergedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .submit();
+
+    ChangeInfo change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+
+    // add new label and assert that it's returned for existing changes
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType verified = Util.verified();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    AccountGroup.UUID registeredUsers =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1,
+        registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.labels.keySet())
+        .containsExactly("Code-Review", "Verified");
+    assertThat(change.permittedLabels.keySet())
+        .containsExactly("Code-Review", "Verified");
+
+    // ignore the new label by Prolog submit rule and assert that the label is
+    // no longer returned
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo,
+        "Ignore Verified",
+        "rules.pl",
+        "submit_rule(submit(CR)) :-\n"
+            + "  gerrit:max_with_block(-2, 2, 'Code-Review', CR).");
+    push2.to(RefNames.REFS_CONFIG);
+
+    change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+
+    // add an approval on the new label and assert that the label is now
+    // returned although it is ignored by the Prolog submit rule and hence not
+    // included in the submit records
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(new ReviewInput().label(
+            verified.getName(), verified.getMax().getValue()));
+
+    change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.labels.keySet())
+        .containsExactly("Code-Review", "Verified");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+
+    // remove label and assert that it's no longer returned for existing
+    // changes, even if there is an approval for it
+    cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().remove(verified.getName());
+    Util.remove(cfg, Permission.forLabel(verified.getName()), registeredUsers,
+        heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+  }
+
+  @Test
+  public void checkLabelsForAutoClosedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result result = push.to("refs/heads/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+  }
+
+  @Test
+  public void checkLabelsForAbandonedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(r.getChangeId())
+        .abandon();
+
+    ChangeInfo change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(change.labels).isEmpty();
+    assertThat(change.permittedLabels).isEmpty();
+  }
+
   private static Iterable<Account.Id> getReviewers(
       Collection<AccountInfo> r) {
-    return Iterables.transform(r, new Function<AccountInfo, Account.Id>() {
-      @Override
-      public Account.Id apply(AccountInfo account) {
-        return new Account.Id(account._accountId);
-      }
-    });
+    return Iterables.transform(r, a -> new Account.Id(a._accountId));
   }
 
   private ChangeResource parseResource(PushOneCommit.Result r)
@@ -1688,4 +2320,42 @@
     assertThat(ctls).hasSize(1);
     return changeResourceFactory.create(ctls.get(0));
   }
+
+  private void setChangeStatus(Change.Id id, Change.Status newStatus)
+      throws Exception {
+    try (BatchUpdate batchUpdate = updateFactory
+        .create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+      batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
+      batchUpdate.execute();
+    }
+
+    ChangeStatus changeStatus = gApi.changes()
+        .id(id.get())
+        .get()
+        .status;
+    assertThat(changeStatus).isEqualTo(newStatus.asChangeStatus());
+  }
+
+  private class ChangeStatusUpdateOp extends BatchUpdate.Op {
+    private final Change.Status newStatus;
+
+    ChangeStatusUpdateOp(Change.Status newStatus) {
+      this.newStatus = newStatus;
+    }
+
+    @Override
+    public boolean updateChange(BatchUpdate.ChangeContext ctx)
+        throws Exception {
+      Change change = ctx.getChange();
+
+      // Change status in database.
+      change.setStatus(newStatus);
+
+      // Change status in NoteDb.
+      PatchSet.Id currentPatchSetId = change.currentPatchSetId();
+      ctx.getUpdate(currentPatchSetId).setStatus(newStatus);
+
+      return true;
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/MergeListIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/MergeListIT.java
new file mode 100644
index 0000000..481df31
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/MergeListIT.java
@@ -0,0 +1,209 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.dircache.InvalidPathException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import java.util.Set;
+
+@NoHttpd
+public class MergeListIT extends AbstractDaemonTest {
+
+  private String changeId;
+  private RevCommit merge;
+  private RevCommit parent1;
+  private RevCommit grandParent1;
+  private RevCommit parent2;
+  private RevCommit grandParent2;
+
+  @Inject
+  private ChangeEditModifier modifier;
+
+  @Inject
+  private ChangeEditUtil editUtil;
+
+  @Before
+  public void setup() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result gp1 = pushFactory
+        .create(db, admin.getIdent(), testRepo, "grand parent 1",
+            ImmutableMap.of("foo", "foo-1.1", "bar", "bar-1.1"))
+        .to("refs/for/master");
+    grandParent1 = gp1.getCommit();
+
+    PushOneCommit.Result p1 = pushFactory
+        .create(db, admin.getIdent(), testRepo, "parent 1",
+            ImmutableMap.of("foo", "foo-1.2", "bar", "bar-1.2"))
+        .to("refs/for/master");
+    parent1 = p1.getCommit();
+
+    // reset HEAD in order to create a sibling of the first change
+    testRepo.reset(initial);
+
+    PushOneCommit.Result gp2 = pushFactory
+        .create(db, admin.getIdent(), testRepo, "grand parent 2",
+            ImmutableMap.of("foo", "foo-2.1", "bar", "bar-2.1"))
+        .to("refs/for/master");
+    grandParent2 = gp2.getCommit();
+
+    PushOneCommit.Result p2 = pushFactory
+        .create(db, admin.getIdent(), testRepo, "parent 2",
+            ImmutableMap.of("foo", "foo-2.2", "bar", "bar-2.2"))
+        .to("refs/for/master");
+    parent2 = p2.getCommit();
+
+    PushOneCommit m = pushFactory.create(db, admin.getIdent(), testRepo,
+        "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(p1.getCommit(), p2.getCommit()));
+    PushOneCommit.Result result = m.to("refs/for/master");
+    result.assertOkStatus();
+    merge = result.getCommit();
+    changeId = result.getChangeId();
+  }
+
+  @Test
+  public void getMergeList() throws Exception {
+    List<CommitInfo> mergeList = current(changeId).getMergeList().get();
+    assertThat(mergeList).hasSize(2);
+    assertThat(mergeList.get(0).commit).isEqualTo(parent2.name());
+    assertThat(mergeList.get(1).commit).isEqualTo(grandParent2.name());
+
+    mergeList = current(changeId).getMergeList()
+        .withUninterestingParent(2).get();
+    assertThat(mergeList).hasSize(2);
+    assertThat(mergeList.get(0).commit).isEqualTo(parent1.name());
+    assertThat(mergeList.get(1).commit).isEqualTo(grandParent1.name());
+  }
+
+  @Test
+  public void getMergeListContent() throws Exception {
+    BinaryResult bin = current(changeId).file(MERGE_LIST).content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String content = new String(os.toByteArray(), UTF_8);
+    assertThat(content).isEqualTo(
+        getMergeListContent(parent2, grandParent2));
+  }
+
+  @Test
+  public void getFileList() throws Exception {
+    assertThat(getFiles(changeId)).contains(MERGE_LIST);
+    assertThat(getFiles(changeId, 1)).contains(MERGE_LIST);
+    assertThat(getFiles(changeId, 2)).contains(MERGE_LIST);
+
+    assertThat(getFiles(createChange().getChangeId()))
+        .doesNotContain(MERGE_LIST);
+  }
+
+  @Test
+  public void getDiffForMergeList() throws Exception {
+    DiffInfo diff = getMergeListDiff(changeId);
+    assertDiffForNewFile(diff, merge, MERGE_LIST,
+        getMergeListContent(parent2, grandParent2));
+
+    diff = getMergeListDiff(changeId, 1);
+    assertDiffForNewFile(diff, merge, MERGE_LIST,
+        getMergeListContent(parent2, grandParent2));
+
+    diff = getMergeListDiff(changeId, 2);
+    assertDiffForNewFile(diff, merge, MERGE_LIST,
+        getMergeListContent(parent1, grandParent1));
+  }
+
+  @Test
+  public void editMergeList() throws Exception {
+    ChangeData cd = getOnlyElement(queryProvider.get().byKeyPrefix(changeId));
+    modifier.createEdit(cd.change(), cd.currentPatchSet());
+
+    exception.expect(InvalidPathException.class);
+    exception.expectMessage("Invalid path: " + MERGE_LIST);
+    modifier.modifyFile(editUtil.byChange(cd.change()).get(), MERGE_LIST,
+        RawInputUtil.create("new content"));
+  }
+
+  @Test
+  public void deleteMergeList() throws Exception {
+    ChangeData cd = getOnlyElement(queryProvider.get().byKeyPrefix(changeId));
+    modifier.createEdit(cd.change(), cd.currentPatchSet());
+
+    exception.expect(InvalidChangeOperationException.class);
+    exception.expectMessage("no changes were made");
+    modifier.deleteFile(editUtil.byChange(cd.change()).get(), MERGE_LIST);
+  }
+
+  private String getMergeListContent(RevCommit... commits) {
+    StringBuilder mergeList = new StringBuilder("Merge List:\n\n");
+    for (RevCommit c : commits) {
+      mergeList.append("* ")
+          .append(c.abbreviate(8).name())
+          .append(" ")
+          .append(c.getShortMessage())
+          .append("\n");
+    }
+    return mergeList.toString();
+  }
+
+  private Set<String> getFiles(String changeId) throws Exception {
+    return current(changeId).files().keySet();
+  }
+
+  private Set<String> getFiles(String changeId, int parent) throws Exception {
+    return current(changeId).files(parent).keySet();
+  }
+
+  private DiffInfo getMergeListDiff(String changeId) throws Exception {
+    return current(changeId).file(MERGE_LIST).diff();
+  }
+
+  private DiffInfo getMergeListDiff(String changeId, int parent)
+      throws Exception {
+    return current(changeId).file(MERGE_LIST).diff(parent);
+  }
+
+  private RevisionApi current(String changeId) throws Exception {
+    return gApi.changes()
+        .id(changeId)
+        .current();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 54fe28f..4da22d3 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -311,6 +311,27 @@
     assertVotes(c, user, 0, 0, REWORK);
   }
 
+  @Test
+  public void deleteStickyVote() throws Exception {
+    String label = "Code-Review";
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get(label)
+        .setCopyMaxScore(true);
+    saveProjectConfig(project, cfg);
+
+
+    // Vote max score on PS1
+    String changeId = createChange(REWORK);
+    vote(admin, changeId, label, 2);
+    assertVotes(detailedChange(changeId), admin, label, 2, null);
+    updateChange(changeId, REWORK);
+    assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
+
+    // Delete vote that was copied via sticky approval
+    deleteVote(admin, changeId, "Code-Review");
+    assertVotes(detailedChange(changeId), admin, label, 0, REWORK);
+  }
+
   private ChangeInfo detailedChange(String changeId) throws Exception {
     return gApi.changes().id(changeId)
         .get(EnumSet.of(ListChangesOption.DETAILED_LABELS,
@@ -495,6 +516,15 @@
     return c.revisions.get(c.currentRevision).kind;
   }
 
+  private void vote(TestAccount user, String changeId, String label, int vote)
+      throws Exception {
+    setApiUser(user);
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(new ReviewInput().label(label, vote));
+  }
+
   private void vote(TestAccount user, String changeId, int codeReviewVote,
       int verifiedVote) throws Exception {
     setApiUser(user);
@@ -504,6 +534,15 @@
     gApi.changes().id(changeId).current().review(in);
   }
 
+  private void deleteVote(TestAccount user, String changeId, String label)
+      throws Exception {
+    setApiUser(user);
+    gApi.changes()
+        .id(changeId)
+        .reviewer(user.getId().toString())
+        .deleteVote(label);
+  }
+
   private void assertVotes(ChangeInfo c, TestAccount user, int codeReviewVote,
       int verifiedVote) {
     assertVotes(c, user, codeReviewVote, verifiedVote, null);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
index 1033164..f132e0d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.extensions.client.SubmitType.FAST_FORWARD_ONLY;
 import static com.google.gerrit.extensions.client.SubmitType.MERGE_ALWAYS;
 import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
+import static com.google.gerrit.extensions.client.SubmitType.REBASE_ALWAYS;
 import static com.google.gerrit.extensions.client.SubmitType.REBASE_IF_NECESSARY;
 import static org.junit.Assert.fail;
 
@@ -123,6 +124,10 @@
       + "gerrit:commit_message(M),"
       + "regex_matches('.*REBASE_IF_NECESSARY.*', M),"
       + "!.\n"
+      + "submit_type(rebase_always) :-"
+      + "gerrit:commit_message(M),"
+      + "regex_matches('.*REBASE_ALWAYS.*', M),"
+      + "!.\n"
       + "submit_type(merge_always) :-"
       + "gerrit:commit_message(M),"
       + "regex_matches('.*MERGE_ALWAYS.*', M),"
@@ -157,8 +162,9 @@
     PushOneCommit.Result r2 = createChange("master", "FAST_FORWARD_ONLY 2");
     PushOneCommit.Result r3 = createChange("master", "MERGE_IF_NECESSARY 3");
     PushOneCommit.Result r4 = createChange("master", "REBASE_IF_NECESSARY 4");
-    PushOneCommit.Result r5 = createChange("master", "MERGE_ALWAYS 5");
-    PushOneCommit.Result r6 = createChange("master", "CHERRY_PICK 6");
+    PushOneCommit.Result r5 = createChange("master", "REBASE_ALWAYS 5");
+    PushOneCommit.Result r6 = createChange("master", "MERGE_ALWAYS 6");
+    PushOneCommit.Result r7 = createChange("master", "CHERRY_PICK 7");
 
     assertSubmitType(MERGE_IF_NECESSARY, r1.getChangeId());
     assertSubmitType(MERGE_IF_NECESSARY, r2.getChangeId());
@@ -166,6 +172,7 @@
     assertSubmitType(MERGE_IF_NECESSARY, r4.getChangeId());
     assertSubmitType(MERGE_IF_NECESSARY, r5.getChangeId());
     assertSubmitType(MERGE_IF_NECESSARY, r6.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r7.getChangeId());
 
     setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
 
@@ -173,8 +180,9 @@
     assertSubmitType(FAST_FORWARD_ONLY, r2.getChangeId());
     assertSubmitType(MERGE_IF_NECESSARY, r3.getChangeId());
     assertSubmitType(REBASE_IF_NECESSARY, r4.getChangeId());
-    assertSubmitType(MERGE_ALWAYS, r5.getChangeId());
-    assertSubmitType(CHERRY_PICK, r6.getChangeId());
+    assertSubmitType(REBASE_ALWAYS, r5.getChangeId());
+    assertSubmitType(MERGE_ALWAYS, r6.getChangeId());
+    assertSubmitType(CHERRY_PICK, r7.getChangeId());
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
index c3c2224..6c301da 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
@@ -31,7 +31,7 @@
         .that(actual.remove(g)).isTrue();
     }
     assert_().withFailureMessage("unexpected groups: " + actual)
-      .that((Iterable<?>)actual).isEmpty();
+      .that(actual).isEmpty();
   }
 
   public static void assertGroupInfo(AccountGroup group, GroupInfo info) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 3f8c1bc..f98e588 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -17,15 +17,12 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.api.group.GroupAssert.assertGroupInfo;
 import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfos;
+import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Ordering;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -399,14 +396,10 @@
 
   @Test
   public void testListAllGroups() throws Exception {
-    List<String> expectedGroups = FluentIterable
-          .from(groupCache.all())
-          .transform(new Function<AccountGroup, String>() {
-            @Override
-            public String apply(AccountGroup group) {
-              return group.getName();
-            }
-          }).toSortedList(Ordering.natural());
+    List<String> expectedGroups = groupCache.all().stream()
+          .map(a -> a.getName())
+          .sorted()
+          .collect(toList());
     assertThat(expectedGroups.size()).isAtLeast(2);
     assertThat(gApi.groups().list().getAsMap().keySet())
         .containsExactlyElementsIn(expectedGroups).inOrder();
@@ -510,7 +503,7 @@
       throws Exception {
     assertMembers(
         gApi.groups().id(group).members(),
-        TestAccount.names(expectedMembers).toArray(String.class));
+        TestAccount.names(expectedMembers).stream().toArray(String[]::new));
     assertAccountInfos(
         Arrays.asList(expectedMembers),
         gApi.groups().id(group).members());
@@ -518,14 +511,7 @@
 
   private void assertMembers(Iterable<AccountInfo> members,
       String... expectedNames) {
-    Iterable<String> memberNames = Iterables.transform(members,
-        new Function<AccountInfo, String>() {
-          @Override
-          public String apply(@Nullable AccountInfo info) {
-            return info.name;
-          }
-        });
-    assertThat(memberNames)
+    assertThat(Iterables.transform(members, i -> i.name))
         .containsExactlyElementsIn(Arrays.asList(expectedNames)).inOrder();
   }
 
@@ -540,15 +526,7 @@
 
   private static void assertIncludes(
       Iterable<GroupInfo> includes, String... expectedNames) {
-    Iterable<String> includeNames = Iterables.transform(
-        includes,
-        new Function<GroupInfo, String>() {
-          @Override
-          public String apply(@Nullable GroupInfo info) {
-            return info.name;
-          }
-        });
-    assertThat(includeNames)
+    assertThat(Iterables.transform(includes, i -> i.name))
         .containsExactlyElementsIn(Arrays.asList(expectedNames)).inOrder();
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index ee2dbfe..2925c1d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -18,19 +18,23 @@
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH;
+import static com.google.gerrit.acceptance.PushOneCommit.PATCH_FILE_ONLY;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
+import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Predicate;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Iterators;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
@@ -38,39 +42,46 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.change.GetRevisionActions;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
-import org.junit.Before;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
 
 import java.io.ByteArrayOutputStream;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -82,13 +93,6 @@
   @Inject
   private GetRevisionActions getRevisionActions;
 
-  private TestAccount admin2;
-
-  @Before
-  public void setUp() throws Exception {
-    admin2 = accounts.admin2();
-  }
-
   @Test
   public void reviewTriplet() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -138,68 +142,113 @@
         .isEqualTo(ChangeStatus.MERGED);
   }
 
-  private void allowSubmitOnBehalfOf() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.allow(cfg,
-        Permission.SUBMIT_AS,
-        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
-        "refs/heads/*");
-    saveProjectConfig(project, cfg);
-  }
-
   @Test
-  public void submitOnBehalfOf() throws Exception {
-    allowSubmitOnBehalfOf();
+  public void postSubmitApproval() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes()
         .id(changeId)
         .current()
-        .review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email;
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .submit(in);
+        .review(ReviewInput.recommend());
+
+    String label = "Code-Review";
+    ApprovalInfo approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+
+    // Submit by direct push.
+    git().push()
+        .setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master"))
+        .call();
     assertThat(gApi.changes().id(changeId).get().status)
         .isEqualTo(ChangeStatus.MERGED);
-  }
 
-  @Test
-  public void submitOnBehalfOfInvalidUser() throws Exception {
-    allowSubmitOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    String changeId = project.get() + "~master~" + r.getChangeId();
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+
+    // Repeating the current label is allowed. Does not flip the postSubmit bit
+    // due to deduplication codepath.
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.recommend());
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+
+    // Reducing vote is not allowed.
+    try {
+      gApi.changes()
+          .id(changeId)
+          .current()
+          .review(ReviewInput.dislike());
+      fail("expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessage(
+          "Cannot reduce vote on labels for closed change: Code-Review");
+    }
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+
+    // Increasing vote is allowed.
     gApi.changes()
         .id(changeId)
         .current()
         .review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = "doesnotexist";
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account Not Found: doesnotexist");
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(2);
+    assertThat(approval.postSubmit).isTrue();
+
+    // Decreasing to previous post-submit vote is still not allowed.
+    try {
+      gApi.changes()
+          .id(changeId)
+          .current()
+          .review(ReviewInput.dislike());
+      fail("expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessage(
+          "Cannot reduce vote on labels for closed change: Code-Review");
+    }
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(2);
+    assertThat(approval.postSubmit).isTrue();
+  }
+
+  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
+  @Test
+  public void approvalCopiedDuringSubmitIsNotPostSubmit() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
     gApi.changes()
-        .id(changeId)
+        .id(id.get())
         .current()
-        .submit(in);
+        .review(ReviewInput.approve());
+    gApi.changes()
+        .id(id.get())
+        .current()
+        .submit();
+
+    ChangeData cd = r.getChange();
+    assertThat(cd.patchSets()).hasSize(2);
+    PatchSetApproval psa = Iterators.getOnlyElement(
+        cd.currentApprovals().stream()
+            .filter(a -> !a.isLegacySubmit()).iterator());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(2);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getValue()).isEqualTo(2);
+    assertThat(psa.isPostSubmit()).isFalse();
   }
 
   @Test
-  public void submitOnBehalfOfNotPermitted() throws Exception {
+  public void voteOnAbandonedChange() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
-        .current()
-        .review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email;
-    exception.expect(AuthException.class);
-    exception.expectMessage("submit on behalf of not permitted");
-    gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
-        .current()
-        .submit(in);
+    gApi.changes().id(r.getChangeId()).abandon();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is closed");
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
   }
 
   @Test
@@ -433,6 +482,111 @@
   }
 
   @Test
+  public void cherryPickMergeRelativeToDefaultParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+
+    ChangeInfo cherryPickedChangeInfo = gApi.changes()
+        .id(mergeChangeResult.getChangeId())
+        .current()
+        .cherryPick(cherryPickInput)
+        .get();
+
+    Map<String, FileInfo> cherryPickedFilesByName =
+        cherryPickedChangeInfo.revisions
+            .get(cherryPickedChangeInfo.currentRevision)
+            .files;
+    assertThat(cherryPickedFilesByName).containsKey(parent2FileName);
+    assertThat(cherryPickedFilesByName).doesNotContainKey(parent1FileName);
+  }
+
+  @Test
+  public void cherryPickMergeRelativeToSpecificParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 2;
+
+    ChangeInfo cherryPickedChangeInfo = gApi.changes()
+        .id(mergeChangeResult.getChangeId())
+        .current()
+        .cherryPick(cherryPickInput)
+        .get();
+
+    Map<String, FileInfo> cherryPickedFilesByName =
+        cherryPickedChangeInfo.revisions
+            .get(cherryPickedChangeInfo.currentRevision)
+            .files;
+    assertThat(cherryPickedFilesByName).containsKey(parent1FileName);
+    assertThat(cherryPickedFilesByName).doesNotContainKey(parent2FileName);
+  }
+
+  @Test
+  public void cherryPickMergeUsingInvalidParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 0;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Cherry Pick: Parent 0 does not exist. Please"
+        + " specify a parent in range [1, 2].");
+    gApi.changes()
+        .id(mergeChangeResult.getChangeId())
+        .current()
+        .cherryPick(cherryPickInput);
+  }
+
+  @Test
+  public void cherryPickMergeUsingNonExistentParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 3;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Cherry Pick: Parent 3 does not exist. Please"
+        + " specify a parent in range [1, 2].");
+    gApi.changes()
+        .id(mergeChangeResult.getChangeId())
+        .current()
+        .cherryPick(cherryPickInput);
+  }
+
+  @Test
   public void canRebase() throws Exception {
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
@@ -511,17 +665,15 @@
   @Test
   public void files() throws Exception {
     PushOneCommit.Result r = createChange();
-    assertThat(Iterables.all(gApi.changes()
+    Map<String, FileInfo> files = gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
-        .files()
-        .keySet(), new Predicate<String>() {
-            @Override
-            public boolean apply(String file) {
-              return file.matches(FILE_NAME + '|' + Patch.COMMIT_MSG);
-            }
-         }))
-      .isTrue();
+        .files();
+    assertThat(files).hasSize(2);
+    assertThat(
+            Iterables.all(
+                files.keySet(), f -> f.matches(FILE_NAME + '|' + COMMIT_MSG)))
+        .isTrue();
   }
 
   @Test
@@ -534,7 +686,7 @@
         .revision(r.getCommit().name())
         .files()
         .keySet()
-      ).containsExactly(Patch.COMMIT_MSG, "foo", "bar");
+      ).containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "bar");
 
     // list files against parent 1
     assertThat(gApi.changes()
@@ -542,7 +694,7 @@
         .revision(r.getCommit().name())
         .files(1)
         .keySet()
-      ).containsExactly(Patch.COMMIT_MSG, "bar");
+      ).containsExactly(COMMIT_MSG, MERGE_LIST, "bar");
 
     // list files against parent 2
     assertThat(gApi.changes()
@@ -550,19 +702,30 @@
         .revision(r.getCommit().name())
         .files(2)
         .keySet()
-      ).containsExactly(Patch.COMMIT_MSG, "foo");
+      ).containsExactly(COMMIT_MSG, MERGE_LIST, "foo");
   }
 
   @Test
   public void diff() throws Exception {
     PushOneCommit.Result r = createChange();
+    assertDiffForNewFile(r, FILE_NAME, FILE_CONTENT);
+    assertDiffForNewFile(r, COMMIT_MSG, r.getCommit().getFullMessage());
+  }
+
+  @Test
+  public void diffDeletedFile() throws Exception {
+    pushFactory.create(db, admin.getIdent(), testRepo)
+        .to("refs/heads/master");
+    PushOneCommit.Result r =
+        pushFactory.create(db, admin.getIdent(), testRepo)
+        .rm("refs/for/master");
     DiffInfo diff = gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
         .file(FILE_NAME)
         .diff();
-    assertThat(diff.metaA).isNull();
-    assertThat(diff.metaB.lines).isEqualTo(1);
+    assertThat(diff.metaA.lines).isEqualTo(1);
+    assertThat(diff.metaB).isNull();
   }
 
   @Test
@@ -610,15 +773,8 @@
   @Test
   public void content() throws Exception {
     PushOneCommit.Result r = createChange();
-    BinaryResult bin = gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .file(FILE_NAME)
-        .content();
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String res = new String(os.toByteArray(), UTF_8);
-    assertThat(res).isEqualTo(FILE_CONTENT);
+    assertContent(r, FILE_NAME, FILE_CONTENT);
+    assertContent(r, COMMIT_MSG, r.getCommit().getFullMessage());
   }
 
   @Test
@@ -769,6 +925,24 @@
   }
 
   @Test
+  public void patchWithPath() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeApi changeApi = gApi.changes()
+        .id(r.getChangeId());
+    BinaryResult bin = changeApi
+        .revision(r.getCommit().name())
+        .patch(FILE_NAME);
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String res = new String(os.toByteArray(), UTF_8);
+    assertThat(res).isEqualTo(PATCH_FILE_ONLY);
+
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("File not found: nonexistent-file.");
+    changeApi.revision(r.getCommit().name()).patch("nonexistent-file");
+  }
+
+  @Test
   public void actions() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(current(r).actions().keySet())
@@ -822,4 +996,107 @@
     assertThat(eTag).isNotEqualTo(oldETag);
     return eTag;
   }
+
+  private void assertContent(PushOneCommit.Result pushResult, String path,
+      String expectedContent) throws Exception {
+    BinaryResult bin = gApi.changes()
+        .id(pushResult.getChangeId())
+        .revision(pushResult.getCommit().name())
+        .file(path)
+        .content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String res = new String(os.toByteArray(), UTF_8);
+    assertThat(res).isEqualTo(expectedContent);
+  }
+
+  private void assertDiffForNewFile(PushOneCommit.Result pushResult, String path,
+      String expectedContentSideB) throws Exception {
+    DiffInfo diff = gApi.changes()
+        .id(pushResult.getChangeId())
+        .revision(pushResult.getCommit().name())
+        .file(path)
+        .diff();
+
+    List<String> headers = new ArrayList<>();
+    if (path.equals(COMMIT_MSG)) {
+      RevCommit c = pushResult.getCommit();
+
+      RevCommit parentCommit = c.getParents()[0];
+      String parentCommitId = testRepo.getRevWalk().getObjectReader()
+          .abbreviate(parentCommit.getId(), 8).name();
+      headers.add("Parent:     " + parentCommitId + " ("
+          + parentCommit.getShortMessage() + ")");
+
+      SimpleDateFormat dtfmt =
+          new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
+      PersonIdent author = c.getAuthorIdent();
+      dtfmt.setTimeZone(author.getTimeZone());
+      headers.add("Author:     " + author.getName() + " <"
+          + author.getEmailAddress() + ">");
+      headers.add("AuthorDate: "
+          + dtfmt.format(Long.valueOf(author.getWhen().getTime())));
+
+      PersonIdent committer = c.getCommitterIdent();
+      dtfmt.setTimeZone(committer.getTimeZone());
+      headers.add("Commit:     " + committer.getName() + " <"
+          + committer.getEmailAddress() + ">");
+      headers.add("CommitDate: "
+          + dtfmt.format(Long.valueOf(committer.getWhen().getTime())));
+      headers.add("");
+    }
+
+    if (!headers.isEmpty()) {
+      String header = Joiner.on("\n").join(headers);
+      expectedContentSideB = header + "\n" + expectedContentSideB;
+    }
+
+    assertDiffForNewFile(diff, pushResult.getCommit(), path,
+        expectedContentSideB);
+  }
+
+  private PushOneCommit.Result createCherryPickableMerge(String parent1FileName,
+      String parent2FileName) throws Exception {
+    RevCommit initialCommit = getHead(repo());
+
+    String branchAName = "branchA";
+    createBranch(new Branch.NameKey(project, branchAName));
+    String branchBName = "branchB";
+    createBranch(new Branch.NameKey(project, branchBName));
+
+    PushOneCommit.Result changeAResult = pushFactory
+        .create(db, admin.getIdent(), testRepo, "change a",
+            parent1FileName, "Content of a")
+        .to("refs/for/" + branchAName);
+
+    testRepo.reset(initialCommit);
+    PushOneCommit.Result changeBResult = pushFactory
+        .create(db, admin.getIdent(), testRepo, "change b",
+            parent2FileName, "Content of b")
+        .to("refs/for/" + branchBName);
+
+    PushOneCommit pushableMergeCommit = pushFactory.create(db, admin.getIdent(),
+        testRepo, "merge", ImmutableMap.of(parent1FileName, "Content of a",
+            parent2FileName, "Content of b"));
+    pushableMergeCommit.setParents(ImmutableList.of(changeAResult.getCommit(),
+        changeBResult.getCommit()));
+    PushOneCommit.Result mergeChangeResult =
+        pushableMergeCommit.to("refs/for/" + branchAName);
+    mergeChangeResult.assertOkStatus();
+    return mergeChangeResult;
+  }
+
+  private ApprovalInfo getApproval(String changeId, String label)
+      throws Exception {
+    ChangeInfo info = gApi.changes()
+        .id(changeId)
+        .get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    LabelInfo li = info.labels.get(label);
+    assertThat(li).isNotNull();
+    int accountId = atrScope.get().getUser().getAccountId().get();
+    return li.all.stream()
+        .filter(a -> a._accountId == accountId)
+        .findFirst()
+        .get();
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
new file mode 100644
index 0000000..7aa5876
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class RobotCommentsIT extends AbstractDaemonTest {
+  @Test
+  public void comments() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    RobotCommentInput in = createRobotCommentInput();
+    ReviewInput reviewInput = new ReviewInput();
+    Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
+    robotComments.put(in.path, Collections.singletonList(in));
+    reviewInput.robotComments = robotComments;
+    reviewInput.message = "comment test";
+    gApi.changes()
+       .id(r.getChangeId())
+       .current()
+       .review(reviewInput);
+
+    Map<String, List<RobotCommentInfo>> out = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .robotComments();
+    assertThat(out).hasSize(1);
+    RobotCommentInfo comment = Iterables.getOnlyElement(out.get(in.path));
+    assertRobotComment(comment, in, false);
+
+    List<RobotCommentInfo> list = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .robotCommentsAsList();
+    assertThat(list).hasSize(1);
+
+    RobotCommentInfo comment2 = list.get(0);
+    assertRobotComment(comment2, in);
+
+    RobotCommentInfo comment3 = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .robotComment(comment.id)
+        .get();
+    assertRobotComment(comment3, in);
+  }
+
+  @Test
+  public void noOptionalFields() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
+    ReviewInput reviewInput = new ReviewInput();
+    Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
+    robotComments.put(in.path, Collections.singletonList(in));
+    reviewInput.robotComments = robotComments;
+    reviewInput.message = "comment test";
+    gApi.changes()
+       .id(r.getChangeId())
+       .current()
+       .review(reviewInput);
+
+    Map<String, List<RobotCommentInfo>> out = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .robotComments();
+    assertThat(out).hasSize(1);
+    RobotCommentInfo comment = Iterables.getOnlyElement(out.get(in.path));
+    assertRobotComment(comment, in, false);
+  }
+
+  @Test
+  public void robotCommentsNotSupported() throws Exception {
+    assume().that(notesMigration.enabled()).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    RobotCommentInput in = createRobotCommentInput();
+    ReviewInput reviewInput = new ReviewInput();
+    Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
+    robotComments.put(FILE_NAME, Collections.singletonList(in));
+    reviewInput.robotComments = robotComments;
+    reviewInput.message = "comment test";
+
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("robot comments not supported");
+    gApi.changes()
+       .id(r.getChangeId())
+       .current()
+       .review(reviewInput);
+  }
+
+  private RobotCommentInput createRobotCommentInputWithMandatoryFields() {
+    RobotCommentInput in = new RobotCommentInput();
+    in.robotId = "happyRobot";
+    in.robotRunId = "1";
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = FILE_NAME;
+    return in;
+  }
+
+  private RobotCommentInput createRobotCommentInput() {
+    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
+    in.url = "http://www.happy-robot.com";
+    in.properties = new HashMap<>();
+    in.properties.put("key1", "value1");
+    in.properties.put("key2", "value2");
+    return in;
+  }
+
+  private void assertRobotComment(RobotCommentInfo c,
+      RobotCommentInput expected) {
+    assertRobotComment(c, expected, true);
+  }
+
+  private void assertRobotComment(RobotCommentInfo c,
+      RobotCommentInput expected, boolean expectPath) {
+    assertThat(c.robotId).isEqualTo(expected.robotId);
+    assertThat(c.robotRunId).isEqualTo(expected.robotRunId);
+    assertThat(c.url).isEqualTo(expected.url);
+    assertThat(c.properties).isEqualTo(expected.properties);
+    assertThat(c.line).isEqualTo(expected.line);
+    assertThat(c.message).isEqualTo(expected.message);
+
+    assertThat(c.author.email).isEqualTo(admin.email);
+
+    if (expectPath) {
+      assertThat(c.path).isEqualTo(expected.path);
+    } else {
+      assertThat(c.path).isNull();
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index e47d570..7830b17e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -20,7 +20,6 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
-import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
@@ -31,6 +30,9 @@
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -84,6 +86,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 public class ChangeEditIT extends AbstractDaemonTest {
 
@@ -172,7 +175,7 @@
     assertThat(
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
             RawInputUtil.create(CONTENT_NEW2))).isEqualTo(RefUpdate.Result.FORCED);
-    editUtil.publish(editUtil.byChange(change).get());
+    editUtil.publish(editUtil.byChange(change).get(), NotifyHandling.NONE);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertThat(edit.isPresent()).isFalse();
     assertChangeMessages(change,
@@ -202,6 +205,24 @@
   }
 
   @Test
+  public void publishEditNotifyRest() throws Exception {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(change.getChangeId()).addReviewer(in);
+
+    modifier.createEdit(change, getCurrentPatchSet(changeId));
+    assertThat(
+        modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
+            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+
+    sender.clear();
+    PublishChangeEditInput input = new PublishChangeEditInput();
+    input.notify = NotifyHandling.NONE;
+    adminRestSession.post(urlPublish(), input).assertNoContent();
+    assertThat(sender.getMessages()).hasSize(0);
+  }
+
+  @Test
   public void deleteEditRest() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     assertThat(
@@ -365,7 +386,7 @@
     edit = editUtil.byChange(change);
     assertThat(edit.get().getEditCommit().getFullMessage()).isEqualTo(msg);
 
-    editUtil.publish(edit.get());
+    editUtil.publish(edit.get(), NotifyHandling.NONE);
     assertThat(editUtil.byChange(change).isPresent()).isFalse();
 
     ChangeInfo info = get(changeId, ListChangesOption.CURRENT_COMMIT,
@@ -408,7 +429,7 @@
       assertThat(readContentFromJson(r)).isEqualTo(commit.getFullMessage());
     }
 
-    editUtil.publish(edit.get());
+    editUtil.publish(edit.get(), NotifyHandling.NONE);
     assertChangeMessages(change,
         ImmutableList.of("Uploaded patch set 1.",
             "Uploaded patch set 2.",
@@ -711,7 +732,7 @@
     assertThat(modifier.modifyMessage(edit.get(), newMsg))
         .isEqualTo(RefUpdate.Result.FORCED);
     edit = editUtil.byChange(change);
-    editUtil.publish(edit.get());
+    editUtil.publish(edit.get(), NotifyHandling.NONE);
 
     ChangeInfo info = get(changeId);
     assertThat(info.subject).isEqualTo(newSubj);
@@ -738,7 +759,7 @@
     editUtil.delete(editUtil.byChange(change).get());
     assertThat(queryEdits()).hasSize(1);
 
-    editUtil.publish(editUtil.byChange(change2).get());
+    editUtil.publish(editUtil.byChange(change2).get(), NotifyHandling.NONE);
     assertThat(queryEdits()).hasSize(0);
 
     setApiUser(user);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index ca669b4..1e2fed5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.project.Util.category;
 import static com.google.gerrit.server.project.Util.value;
@@ -33,6 +34,7 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -40,6 +42,7 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.EditInfo;
@@ -61,6 +64,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
@@ -68,6 +72,7 @@
 
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -129,6 +134,27 @@
   }
 
   @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void pushInitialCommitForMasterBranch() throws Exception {
+    RevCommit c =
+        testRepo.commit().message("Initial commit").insertChangeId().create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    String r = "refs/for/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    ChangeInfo change = gApi.changes().id(id).info();
+    assertThat(change.branch).isEqualTo("master");
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve("master")).isNull();
+    }
+  }
+
+  @Test
   public void output() throws Exception {
     String url = canonicalWebUrl.get();
     ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
@@ -176,6 +202,21 @@
   }
 
   @Test
+  public void pushForMasterWithTopicOption() throws Exception {
+    String topicOption = "topic=myTopic";
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add(topicOption);
+
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, "myTopic");
+    r.assertPushOptions(pushOptions);
+  }
+
+  @Test
   public void pushForMasterWithNotify() throws Exception {
     TestAccount user2 = accounts.user2();
     String pushSpec = "refs/for/master"
@@ -611,6 +652,58 @@
     assertTwoChangesWithSameRevision(r);
   }
 
+  @Test
+  public void pushSameCommitTwice() throws Exception {
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config.getProject()
+        .setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
+    saveProjectConfig(project, config);
+
+    PushOneCommit push =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
+                "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    push =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
+                "b.txt", "anotherContent");
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    assertPushRejected(pushHead(testRepo, "refs/for/master", false),
+        "refs/for/master", "commit(s) already exists (as current patchset)");
+  }
+
+  @Test
+  public void pushSameCommitTwiceWhenIndexFailed() throws Exception {
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config.getProject()
+        .setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
+    saveProjectConfig(project, config);
+
+    PushOneCommit push =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
+                "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    push =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
+                "b.txt", "anotherContent");
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    indexer.delete(r.getChange().getId());
+
+    assertPushRejected(pushHead(testRepo, "refs/for/master", false),
+        "refs/for/master", "commit(s) already exists (as current patchset)");
+  }
+
   private void assertTwoChangesWithSameRevision(PushOneCommit.Result result)
       throws Exception {
     List<ChangeInfo> changes = query(result.getCommit().name());
@@ -783,6 +876,179 @@
     pushWithReviewerInFooter("Notauser", null);
   }
 
+  @Test
+  public void pushNewPatchsetOverridingStickyLabel() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType codeReview = Util.codeReview();
+    codeReview.setCopyMaxScore(true);
+    cfg.getLabelSections().put(codeReview.getName(), codeReview);
+    saveProjectConfig(cfg);
+
+    PushOneCommit.Result r = pushTo("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
+            "b.txt", "anotherContent", r.getChangeId());
+    r = push.to("refs/for/master%l=Code-Review+1");
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void createChangeForMergedCommit() throws Exception {
+    String master = "refs/heads/master";
+    grant(Permission.PUSH, project, master, true);
+
+    // Update master with a direct push.
+    RevCommit c1 = testRepo.commit()
+        .message("Non-change 1")
+        .create();
+    RevCommit c2 = testRepo.parseBody(
+        testRepo.commit()
+            .parent(c1)
+            .message("Non-change 2")
+            .insertChangeId()
+            .create());
+    String changeId = Iterables.getOnlyElement(c2.getFooterLines(CHANGE_ID));
+
+    testRepo.reset(c2);
+    assertPushOk(pushHead(testRepo, master, false, true), master);
+
+    String q = "commit:" + c1.name()
+        + " OR commit:" + c2.name()
+        + " OR change:" + changeId;
+    assertThat(gApi.changes().query(q).get()).isEmpty();
+
+    // Push c2 as a merged change.
+    String r = "refs/for/master%merged";
+    assertPushOk(pushHead(testRepo, r, false), r);
+
+    EnumSet<ListChangesOption> opts =
+        EnumSet.of(ListChangesOption.CURRENT_REVISION);
+    ChangeInfo info = gApi.changes().id(changeId).get(opts);
+    assertThat(info.currentRevision).isEqualTo(c2.name());
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+
+    // Only c2 was created as a change.
+    String q1 = "commit: " + c1.name();
+    assertThat(gApi.changes().query(q1).get()).isEmpty();
+
+    // Push c1 as a merged change.
+    testRepo.reset(c1);
+    assertPushOk(pushHead(testRepo, r, false), r);
+    List<ChangeInfo> infos =
+        gApi.changes().query(q1).withOptions(opts).get();
+    assertThat(infos).hasSize(1);
+    info = infos.get(0);
+    assertThat(info.currentRevision).isEqualTo(c1.name());
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void mergedOptionFailsWhenCommitIsNotMerged() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master%merged");
+    r.assertErrorStatus("not merged into branch");
+  }
+
+  @Test
+  public void mergedOptionFailsWhenCommitIsMergedOnOtherBranch()
+      throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      tr.branch("refs/heads/branch")
+          .commit()
+          .message("Initial commit on branch")
+          .create();
+    }
+
+    pushTo("refs/for/master%merged")
+        .assertErrorStatus("not merged into branch");
+  }
+
+  @Test
+  public void mergedOptionFailsWhenChangeExists() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    testRepo.reset(r.getCommit());
+    String ref = "refs/for/master%merged";
+    PushResult pr = pushHead(testRepo, ref, false);
+    RemoteRefUpdate rru = pr.getRemoteUpdate(ref);
+    assertThat(rru.getStatus())
+        .isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(rru.getMessage()).contains("no new changes");
+  }
+
+  @Test
+  public void mergedOptionWithNewCommitWithSameChangeIdFails()
+      throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    RevCommit c2 = testRepo.amend(r.getCommit())
+        .message("New subject")
+        .insertChangeId(r.getChangeId().substring(1))
+        .create();
+    testRepo.reset(c2);
+
+    String ref = "refs/for/master%merged";
+    PushResult pr = pushHead(testRepo, ref, false);
+    RemoteRefUpdate rru = pr.getRemoteUpdate(ref);
+    assertThat(rru.getStatus())
+        .isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(rru.getMessage()).contains("not merged into branch");
+  }
+
+  @Test
+  public void mergedOptionWithExistingChangeInsertsPatchSet()
+      throws Exception {
+    String master = "refs/heads/master";
+    grant(Permission.PUSH, project, master, true);
+
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    ObjectId c1 = r.getCommit().copy();
+
+    // Create a PS2 commit directly on master in the server's repo. This
+    // simulates the client amending locally and pushing directly to the branch,
+    // expecting the change to be auto-closed, but the change metadata update
+    // fails.
+    ObjectId c2;
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      RevCommit commit2 = tr.amend(c1)
+          .message("New subject")
+          .insertChangeId(r.getChangeId().substring(1))
+          .create();
+      c2 = commit2.copy();
+      tr.update(master, c2);
+    }
+
+    testRepo.git().fetch()
+        .setRefSpecs(new RefSpec("refs/heads/master")).call();
+    testRepo.reset(c2);
+
+    String ref = "refs/for/master%merged";
+    assertPushOk(pushHead(testRepo, ref, false), ref);
+
+    EnumSet<ListChangesOption> opts =
+        EnumSet.of(ListChangesOption.ALL_REVISIONS);
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(opts);
+    assertThat(info.currentRevision).isEqualTo(c2.name());
+    assertThat(info.revisions.keySet())
+        .containsExactly(c1.name(), c2.name());
+    // TODO(dborowitz): Fix ReceiveCommits to also auto-close the change.
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
   private void pushWithReviewerInFooter(String nameEmail,
       TestAccount expectedReviewer) throws Exception {
     int n = 5;
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index 28f7ff8..2b2759c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -29,6 +29,7 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevTree;
@@ -60,14 +61,21 @@
     return cfg;
   }
 
-  protected static Config submitByCherryPickConifg() {
+  protected static Config submitByCherryPickConfig() {
     Config cfg = new Config();
     cfg.setBoolean("change", null, "submitWholeTopic", true);
     cfg.setEnum("project", null, "submitType", SubmitType.CHERRY_PICK);
     return cfg;
   }
 
-  protected static Config submitByRebaseConifg() {
+  protected static Config submitByRebaseAlwaysConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    cfg.setEnum("project", null, "submitType", SubmitType.REBASE_ALWAYS);
+    return cfg;
+  }
+
+  protected static Config submitByRebaseIfNecessaryConfig() {
     Config cfg = new Config();
     cfg.setBoolean("change", null, "submitWholeTopic", true);
     cfg.setEnum("project", null, "submitType", SubmitType.REBASE_IF_NECESSARY);
@@ -75,8 +83,9 @@
   }
 
   protected TestRepository<?> createProjectWithPush(String name,
-      @Nullable Project.NameKey parent, SubmitType submitType) throws Exception {
-    Project.NameKey project = createProject(name, parent, submitType);
+      @Nullable Project.NameKey parent, boolean createEmptyCommit,
+      SubmitType submitType) throws Exception {
+    Project.NameKey project = createProject(name, parent, createEmptyCommit, submitType);
     grant(Permission.PUSH, project, "refs/heads/*");
     grant(Permission.SUBMIT, project, "refs/for/refs/heads/*");
     return cloneProject(project);
@@ -84,12 +93,17 @@
 
   protected TestRepository<?> createProjectWithPush(String name,
       @Nullable Project.NameKey parent) throws Exception {
-    return createProjectWithPush(name, parent, getSubmitType());
+    return createProjectWithPush(name, parent, true, getSubmitType());
+  }
+
+  protected TestRepository<?> createProjectWithPush(String name,
+      boolean createEmptyCommit) throws Exception {
+    return createProjectWithPush(name, null, createEmptyCommit, getSubmitType());
   }
 
   protected TestRepository<?> createProjectWithPush(String name)
       throws Exception {
-    return createProjectWithPush(name, null);
+    return createProjectWithPush(name, null, true, getSubmitType());
   }
 
   private static AtomicInteger contentCounter = new AtomicInteger(0);
@@ -137,8 +151,13 @@
     Project.NameKey superName = new Project.NameKey(name(superproject));
     try (MetaDataUpdate md = metaDataUpdateFactory.create(sub)) {
       md.setMessage("Added superproject subscription");
+      SubscribeSection s;
       ProjectConfig pc = ProjectConfig.read(md);
-      SubscribeSection s = new SubscribeSection(superName);
+      if (pc.getSubscribeSections().containsKey(superName)) {
+        s = pc.getSubscribeSections().get(superName);
+      } else {
+        s = new SubscribeSection(superName);
+      }
       String refspec;
       if (superBranch == null) {
         refspec = subBranch;
@@ -305,8 +324,13 @@
       String submodule) throws Exception {
 
     submodule = name(submodule);
-    ObjectId commitId = repo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/" + branch).getObjectId();
+    Ref branchTip = repo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/" + branch);
+    if (branchTip == null) {
+      return false;
+    }
+
+    ObjectId commitId = branchTip.getObjectId();
 
     RevWalk rw = repo.getRevWalk();
     RevCommit c = rw.parseCommit(commitId);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUCK
index f6796a5..42ece25 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUCK
@@ -6,6 +6,7 @@
   deps = [
     ':submodule_util',
     ':push_for_review',
+    '//gerrit-extension-api:api',
   ],
   labels = ['git'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
similarity index 62%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java
rename to gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index fd2385b..521ccc4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
@@ -34,18 +35,25 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.ReceiveCommitsAdvertiseRefsHook;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testutil.DisabledReviewDb;
+import com.google.gerrit.testutil.TestChanges;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
@@ -53,11 +61,12 @@
 import org.junit.Test;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
 @NoHttpd
-public class VisibleRefFilterIT extends AbstractDaemonTest {
+public class RefAdvertisementIT extends AbstractDaemonTest {
   @Inject
   private ChangeEditModifier editModifier;
 
@@ -74,12 +83,23 @@
   @Inject
   private Provider<CurrentUser> userProvider;
 
+  @Inject
+  private ChangeNoteUtil noteUtil;
+
+  @Inject
+  @AnonymousCowardName
+  private String anonymousCowardName;
+
   private AccountGroup.UUID admins;
 
-  private Change.Id c1;
-  private Change.Id c2;
+  private ChangeData c1;
+  private ChangeData c2;
+  private ChangeData c3;
+  private ChangeData c4;
   private String r1;
   private String r2;
+  private String r3;
+  private String r4;
 
   @Before
   public void setUp() throws Exception {
@@ -111,17 +131,31 @@
         .branch("branch")
         .create(new BranchInput());
 
+    // First 2 changes are merged, which means the tags pointing to them are
+    // visible.
     allow(Permission.SUBMIT, admins, "refs/for/refs/heads/*");
     PushOneCommit.Result mr = pushFactory.create(db, admin.getIdent(), testRepo)
         .to("refs/for/master%submit");
     mr.assertOkStatus();
-    c1 = mr.getChange().getId();
-    r1 = changeRefPrefix(c1);
+    c1 = mr.getChange();
+    r1 = changeRefPrefix(c1.getId());
     PushOneCommit.Result br = pushFactory.create(db, admin.getIdent(), testRepo)
         .to("refs/for/branch%submit");
     br.assertOkStatus();
-    c2 = br.getChange().getId();
-    r2 = changeRefPrefix(c2);
+    c2 = br.getChange();
+    r2 = changeRefPrefix(c2.getId());
+
+    // Second 2 changes are unmerged.
+    mr = pushFactory.create(db, admin.getIdent(), testRepo)
+        .to("refs/for/master");
+    mr.assertOkStatus();
+    c3 = mr.getChange();
+    r3 = changeRefPrefix(c3.getId());
+    br = pushFactory.create(db, admin.getIdent(), testRepo)
+        .to("refs/for/branch");
+    br.assertOkStatus();
+    c4 = br.getChange();
+    r4 = changeRefPrefix(c4.getId());
 
     try (Repository repo = repoManager.openRepository(project)) {
       // master-tag -> master
@@ -139,7 +173,7 @@
   }
 
   @Test
-  public void allRefsVisibleNoRefsMetaConfig() throws Exception {
+  public void uploadPackAllRefsVisibleNoRefsMetaConfig() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     Util.allow(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
     Util.allow(cfg, Permission.READ, admins, RefNames.REFS_CONFIG);
@@ -147,12 +181,16 @@
     saveProjectConfig(project, cfg);
 
     setApiUser(user);
-    assertRefs(
+    assertUploadPackRefs(
         "HEAD",
         r1 + "1",
         r1 + "meta",
         r2 + "1",
         r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        r4 + "1",
+        r4 + "meta",
         "refs/heads/branch",
         "refs/heads/master",
         "refs/tags/branch-tag",
@@ -160,16 +198,20 @@
   }
 
   @Test
-  public void allRefsVisibleWithRefsMetaConfig() throws Exception {
+  public void uploadPackAllRefsVisibleWithRefsMetaConfig() throws Exception {
     allow(Permission.READ, REGISTERED_USERS, "refs/*");
     allow(Permission.READ, REGISTERED_USERS, RefNames.REFS_CONFIG);
 
-    assertRefs(
+    assertUploadPackRefs(
         "HEAD",
         r1 + "1",
         r1 + "meta",
         r2 + "1",
         r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        r4 + "1",
+        r4 + "meta",
         "refs/heads/branch",
         "refs/heads/master",
         RefNames.REFS_CONFIG,
@@ -178,28 +220,32 @@
   }
 
   @Test
-  public void subsetOfBranchesVisibleIncludingHead() throws Exception {
+  public void uploadPackSubsetOfBranchesVisibleIncludingHead() throws Exception {
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
     deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
     setApiUser(user);
-    assertRefs(
+    assertUploadPackRefs(
         "HEAD",
         r1 + "1",
         r1 + "meta",
+        r3 + "1",
+        r3 + "meta",
         "refs/heads/master",
         "refs/tags/master-tag");
   }
 
   @Test
-  public void subsetOfBranchesVisibleNotIncludingHead() throws Exception {
+  public void uploadPackSubsetOfBranchesVisibleNotIncludingHead() throws Exception {
     deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
     setApiUser(user);
-    assertRefs(
+    assertUploadPackRefs(
         r2 + "1",
         r2 + "meta",
+        r4 + "1",
+        r4 + "meta",
         "refs/heads/branch",
         "refs/tags/branch-tag",
         // master branch is not visible but master-tag is reachable from branch
@@ -208,12 +254,12 @@
   }
 
   @Test
-  public void subsetOfBranchesVisibleWithEdit() throws Exception {
+  public void uploadPackSubsetOfBranchesVisibleWithEdit() throws Exception {
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
     deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
-    Change c = notesFactory.createChecked(db, project, c1).getChange();
-    PatchSet ps1 = getPatchSet(new PatchSet.Id(c1, 1));
+    Change c = notesFactory.createChecked(db, project, c1.getId()).getChange();
+    PatchSet ps1 = getPatchSet(new PatchSet.Id(c1.getId(), 1));
 
     // Admin's edit is not visible.
     setApiUser(admin);
@@ -223,59 +269,64 @@
     setApiUser(user);
     editModifier.createEdit(c, ps1);
 
-    assertRefs(
+    assertUploadPackRefs(
         "HEAD",
         r1 + "1",
         r1 + "meta",
+        r3 + "1",
+        r3 + "meta",
         "refs/heads/master",
         "refs/tags/master-tag",
-        "refs/users/01/1000001/edit-" + c1.get() + "/1");
+        "refs/users/01/1000001/edit-" + c1.getId() + "/1");
   }
 
   @Test
-  public void subsetOfRefsVisibleWithAccessDatabase() throws Exception {
+  public void uploadPackSubsetOfRefsVisibleWithAccessDatabase() throws Exception {
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
     try {
       deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
       allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
-      Change c = notesFactory.createChecked(db, project, c1).getChange();
-      PatchSet ps1 = getPatchSet(new PatchSet.Id(c1, 1));
+      PatchSet ps1 = getPatchSet(new PatchSet.Id(c1.getId(), 1));
       setApiUser(admin);
-      editModifier.createEdit(c, ps1);
+      editModifier.createEdit(c1.change(), ps1);
       setApiUser(user);
 
-      assertRefs(
+      assertUploadPackRefs(
           // Change 1 is visible due to accessDatabase capability, even though
           // refs/heads/master is not.
           r1 + "1",
           r1 + "meta",
           r2 + "1",
           r2 + "meta",
+          r3 + "1",
+          r3 + "meta",
+          r4 + "1",
+          r4 + "meta",
           "refs/heads/branch",
           "refs/tags/branch-tag",
           // See comment in subsetOfBranchesVisibleNotIncludingHead.
           "refs/tags/master-tag",
           // All edits are visible due to accessDatabase capability.
-          "refs/users/00/1000000/edit-" + c1.get() + "/1");
+          "refs/users/00/1000000/edit-" + c1.getId() + "/1");
     } finally {
       removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
     }
   }
 
   @Test
-  public void draftRefs() throws Exception {
+  public void uploadPackDraftRefs() throws Exception {
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/*");
 
     PushOneCommit.Result br = pushFactory.create(db, admin.getIdent(), testRepo)
         .to("refs/drafts/master");
     br.assertOkStatus();
-    Change.Id c3 = br.getChange().getId();
-    String r3 = changeRefPrefix(c3);
+    Change.Id c5 = br.getChange().getId();
+    String r5 = changeRefPrefix(c5);
 
-    // Only admin can see admin's draft change.
+    // Only admin can see admin's draft change (5).
     setApiUser(admin);
-    assertRefs(
+    assertUploadPackRefs(
         "HEAD",
         r1 + "1",
         r1 + "meta",
@@ -283,6 +334,10 @@
         r2 + "meta",
         r3 + "1",
         r3 + "meta",
+        r4 + "1",
+        r4 + "meta",
+        r5 + "1",
+        r5 + "meta",
         "refs/heads/branch",
         "refs/heads/master",
         RefNames.REFS_CONFIG,
@@ -291,12 +346,16 @@
 
     // user can't.
     setApiUser(user);
-    assertRefs(
+    assertUploadPackRefs(
         "HEAD",
         r1 + "1",
         r1 + "meta",
         r2 + "1",
         r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        r4 + "1",
+        r4 + "meta",
         "refs/heads/branch",
         "refs/heads/master",
         "refs/tags/branch-tag",
@@ -304,7 +363,7 @@
   }
 
   @Test
-  public void noSearchingChangeCacheImpl() throws Exception {
+  public void uploadPackNoSearchingChangeCacheImpl() throws Exception {
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/*");
 
     setApiUser(user);
@@ -320,6 +379,10 @@
           r1 + "meta",
           r2 + "1",
           r2 + "meta",
+          r3 + "1",
+          r3 + "meta",
+          r4 + "1",
+          r4 + "meta",
           "refs/heads/branch",
           "refs/heads/master",
           "refs/tags/branch-tag",
@@ -328,7 +391,7 @@
   }
 
   @Test
-  public void sequencesWithAccessDatabase() throws Exception {
+  public void uploadPackSequencesWithAccessDatabase() throws Exception {
     assume().that(notesMigration.readChangeSequence()).isTrue();
     try (Repository repo = repoManager.openRepository(allProjects)) {
       setApiUser(user);
@@ -348,6 +411,82 @@
     }
   }
 
+  @Test
+  public void receivePackListsOpenChangesAsAdditionalHaves() throws Exception {
+    ReceiveCommitsAdvertiseRefsHook.Result r = getReceivePackRefs();
+    assertThat(r.allRefs().keySet()).containsExactly(
+        // meta refs are excluded even when NoteDb is enabled.
+        "HEAD",
+        "refs/heads/branch",
+        "refs/heads/master",
+        "refs/meta/config",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+    assertThat(r.additionalHaves()).containsExactly(obj(c3, 1), obj(c4, 1));
+  }
+
+  @Test
+  public void receivePackRespectsVisibilityOfOpenChanges() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+    setApiUser(user);
+
+    assertThat(getReceivePackRefs().additionalHaves())
+        .containsExactly(obj(c3, 1));
+  }
+
+  @Test
+  public void receivePackListsOnlyLatestPatchSet() throws Exception {
+    testRepo.reset(obj(c3, 1));
+    PushOneCommit.Result r = amendChange(c3.change().getKey().get());
+    r.assertOkStatus();
+    c3 = r.getChange();
+    assertThat(getReceivePackRefs().additionalHaves())
+        .containsExactly(obj(c3, 2), obj(c4, 1));
+  }
+
+  @Test
+  public void receivePackOmitsMissingObject() throws Exception {
+    // Use the tactic from ConsistencyCheckerIT to insert a new patch set with a
+    // missing object.
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      String subject = "Subject for missing commit";
+      Change c = new Change(c3.change());
+      PatchSet.Id psId = new PatchSet.Id(c3.getId(), 2);
+      c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
+
+      PatchSet ps = TestChanges.newPatchSet(psId, rev, admin.getId());
+      db.patchSets().insert(Collections.singleton(ps));
+      db.changes().update(Collections.singleton(c));
+
+      if (notesMigration.commitChangeWrites()) {
+        PersonIdent committer = serverIdent.get();
+        PersonIdent author = noteUtil.newIdent(
+            accountCache.get(admin.getId()).getAccount(),
+            committer.getWhen(),
+            committer,
+            anonymousCowardName);
+        tr.branch(RefNames.changeMetaRef(c3.getId()))
+            .commit()
+            .author(author)
+            .committer(committer)
+            .message(
+                "Update patch set " + psId.get() + "\n"
+                    + "\n"
+                    + "Patch-set: " + psId.get() + "\n"
+                    + "Commit: " + rev + "\n"
+                    + "Subject: " + subject + "\n")
+            .create();
+      }
+      indexer.index(db, c.getProject(), c.getId());
+    }
+
+    assertThat(getReceivePackRefs().additionalHaves())
+        .containsExactly(obj(c4, 1));
+  }
+
   /**
    * Assert that refs seen by a non-admin user match expected.
    *
@@ -356,7 +495,8 @@
    *     from the expected list before comparing to the actual results.
    * @throws Exception
    */
-  private void assertRefs(String... expectedWithMeta) throws Exception {
+  private void assertUploadPackRefs(String... expectedWithMeta)
+      throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       assertRefs(
           repo,
@@ -391,6 +531,15 @@
     }
   }
 
+  private ReceiveCommitsAdvertiseRefsHook.Result getReceivePackRefs()
+      throws Exception {
+    ReceiveCommitsAdvertiseRefsHook hook =
+        new ReceiveCommitsAdvertiseRefsHook(queryProvider, project);
+    try (Repository repo = repoManager.openRepository(project)) {
+      return hook.advertiseRefs(repo.getAllRefs());
+    }
+  }
+
   private ProjectControl projectControl() throws Exception {
     return projectControlFactory.controlFor(project, userProvider.get());
   }
@@ -402,4 +551,12 @@
         projectControlFactory.controlFor(project, userProvider.get()),
         db, true);
   }
+
+  private static ObjectId obj(ChangeData cd, int psNum) throws Exception {
+    PatchSet.Id psId = new PatchSet.Id(cd.getId(), psNum);
+    PatchSet ps = cd.patchSet(psId);
+    assertWithMessage("%s not found in %s", psId, cd.patchSets()).that(ps)
+        .isNotNull();
+    return ObjectId.fromString(ps.getRevision().get());
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index 848b428..ae2e8aa 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -77,6 +77,7 @@
   @Test
   public void submitOnPushWithAnnotatedTag() throws Exception {
     grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(Permission.PUSH, project, "refs/tags/*");
     PushOneCommit.AnnotatedTag tag =
         new PushOneCommit.AnnotatedTag("v1.0", "annotation", admin.getIdent());
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 0ff3af5..8283dba 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -21,15 +21,22 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testutil.ConfigSuite;
 
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
 
+import java.util.Map;
+
 @NoHttpd
 public class SubmoduleSubscriptionsWholeTopicMergeIT
   extends AbstractSubmoduleSubscription {
@@ -46,12 +53,17 @@
 
   @ConfigSuite.Config
   public static Config cherryPick() {
-    return submitByCherryPickConifg();
+    return submitByCherryPickConfig();
   }
 
   @ConfigSuite.Config
-  public static Config rebase() {
-    return submitByRebaseConifg();
+  public static Config rebaseAlways() {
+    return submitByRebaseAlwaysConfig();
+  }
+
+  @ConfigSuite.Config
+  public static Config rebaseIfNecessary() {
+    return submitByRebaseIfNecessaryConfig();
   }
 
   @Test
@@ -101,22 +113,51 @@
     gApi.changes().id(id2).current().review(ReviewInput.approve());
     gApi.changes().id(id3).current().review(ReviewInput.approve());
 
+    BinaryResult request = gApi.changes().id(id1).current().submitPreview();
+    Map<Branch.NameKey, RevTree> preview =
+        fetchFromBundles(request);
+
     gApi.changes().id(id1).current().submit();
     ObjectId subRepoId = subRepo.git().fetch().setRemote("origin").call()
         .getAdvertisedRef("refs/heads/master").getObjectId();
 
     expectToHaveSubmoduleState(superRepo, "master",
         "subscribed-to-project", subRepoId);
+
+    // As the submodules have changed commits, the superproject tree will be
+    // different, so we cannot directly compare the trees here, so make
+    // assumptions only about the changed branches:
+    Project.NameKey p1 = new Project.NameKey(name("super-project"));
+    Project.NameKey p2 = new Project.NameKey(name("subscribed-to-project"));
+    assertThat(preview).containsKey(
+        new Branch.NameKey(p1, "refs/heads/master"));
+    assertThat(preview).containsKey(
+        new Branch.NameKey(p2, "refs/heads/master"));
+
+    if ((getSubmitType() == SubmitType.CHERRY_PICK)
+        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
+      // each change is updated and the respective target branch is updated:
+      assertThat(preview).hasSize(5);
+    } else if ((getSubmitType() == SubmitType.REBASE_IF_NECESSARY)) {
+      // Either the first is used first as is, then the second and third need
+      // rebasing, or those two stay as is and the first is rebased.
+      // add in 2 master branches, expect 3 or 4:
+      assertThat(preview.size()).isAnyOf(3, 4);
+    } else {
+      assertThat(preview).hasSize(2);
+    }
   }
 
   @Test
-  public void testSubscriptionUpdateIncludingChangeInSuperproject() throws Exception {
+  public void testSubscriptionUpdateIncludingChangeInSuperproject()
+      throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("subscribed-to-project",
+        "refs/heads/master", "super-project", "refs/heads/master");
 
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
 
     ObjectId subHEAD = subRepo.branch("HEAD").commit().insertChangeId()
         .message("some change")
@@ -223,7 +264,57 @@
   }
 
   @Test
-  public void testDifferentPaths() throws Exception {
+  public void testDoNotUseFastForward() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project", false);
+    TestRepository<?> sub = createProjectWithPush("sub", false);
+
+    allowMatchingSubmoduleSubscription("sub", "refs/heads/master",
+        "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "sub", "master");
+
+    ObjectId subId =
+        pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+
+    ObjectId superId =
+        pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
+
+    String subChangeId = getChangeId(sub, subId).get();
+    approve(subChangeId);
+    approve(getChangeId(superRepo, superId).get());
+
+    gApi.changes().id(subChangeId).current().submit();
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
+    RevCommit superHead = getRemoteHead(name("super-project"), "master");
+    assertThat(superHead.getShortMessage()).contains("some message");
+    assertThat(superHead.getId()).isNotEqualTo(superId);
+  }
+
+  @Test
+  public void testUseFastForwardWhenNoSubmodule() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project", false);
+    TestRepository<?> sub = createProjectWithPush("sub", false);
+
+    ObjectId subId =
+        pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+
+    ObjectId superId =
+        pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
+
+    String subChangeId = getChangeId(sub, subId).get();
+    approve(subChangeId);
+    approve(getChangeId(superRepo, superId).get());
+
+    gApi.changes().id(subChangeId).current().submit();
+
+    RevCommit superHead = getRemoteHead(name("super-project"), "master");
+    assertThat(superHead.getShortMessage()).isEqualTo("some message");
+    assertThat(superHead.getId()).isEqualTo(superId);
+  }
+
+  @Test
+  public void testSameProjectSameBranchDifferentPaths() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> sub = createProjectWithPush("sub");
 
@@ -256,6 +347,50 @@
   }
 
   @Test
+  public void testSameProjectDifferentBranchDifferentPaths() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub = createProjectWithPush("sub");
+
+    allowMatchingSubmoduleSubscription("sub", "refs/heads/master",
+        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("sub", "refs/heads/dev",
+        "super-project", "refs/heads/master");
+
+    ObjectId devHead = pushChangeTo(sub, "dev");
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "sub", "sub-master", "master");
+    prepareSubmoduleConfigEntry(config, "sub", "sub-dev", "dev");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    ObjectId subMasterId =
+        pushChangeTo(sub, "refs/for/master", "some message", "b.txt",
+            "content b", "same-topic");
+
+    sub.reset(devHead);
+    ObjectId subDevId =
+        pushChangeTo(sub, "refs/for/dev", "some message in dev", "b.txt",
+            "content b", "same-topic");
+
+    approve(getChangeId(sub, subMasterId).get());
+    approve(getChangeId(sub, subDevId).get());
+
+    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
+
+    gApi.changes().id(getChangeId(sub, subMasterId).get()).current().submit();
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub-master", sub, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "sub-dev", sub, "dev");
+
+    superRepo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/master").getObjectId();
+
+    assertWithMessage("submodule subscription update "
+        + "should have made one commit")
+        .that(superRepo.getRepository().resolve("origin/master^"))
+        .isEqualTo(superPreviousId);
+  }
+
+  @Test
   public void testNonSubmoduleInSameTopic() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> sub = createProjectWithPush("sub");
@@ -310,7 +445,8 @@
     createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
 
     ObjectId bottomHead =
-        pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
+        pushChangeTo(bottomRepo, "refs/for/master",
+            "some message", "same-topic");
     ObjectId topHead =
         pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
 
@@ -322,8 +458,10 @@
 
     gApi.changes().id(id1).current().submit();
 
-    expectToHaveSubmoduleState(midRepo, "master", "bottom-project", bottomRepo, "master");
-    expectToHaveSubmoduleState(topRepo, "master", "mid-project", midRepo, "master");
+    expectToHaveSubmoduleState(midRepo, "master", "bottom-project",
+        bottomRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master", "mid-project",
+        midRepo, "master");
   }
 
   @Test
@@ -346,7 +484,8 @@
     pushSubmoduleConfig(topRepo, "master", config);
 
     ObjectId bottomHead =
-        pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
+        pushChangeTo(bottomRepo, "refs/for/master",
+            "some message", "same-topic");
     ObjectId topHead =
         pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
 
@@ -358,13 +497,16 @@
 
     gApi.changes().id(id1).current().submit();
 
-    expectToHaveSubmoduleState(midRepo, "master", "bottom-project", bottomRepo, "master");
-    expectToHaveSubmoduleState(topRepo, "master", "mid-project", midRepo, "master");
-    expectToHaveSubmoduleState(topRepo, "master", "bottom-project", bottomRepo, "master");
+    expectToHaveSubmoduleState(midRepo, "master",
+        "bottom-project", bottomRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master",
+        "mid-project", midRepo, "master");
+    expectToHaveSubmoduleState(topRepo, "master",
+        "bottom-project", bottomRepo, "master");
   }
 
-  @Test
-  public void testBranchCircularSubscription() throws Exception {
+
+  private String prepareBranchCircularSubscription() throws Exception {
     TestRepository<?> topRepo = createProjectWithPush("top-project");
     TestRepository<?> midRepo = createProjectWithPush("mid-project");
     TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
@@ -385,15 +527,23 @@
     String changeId = getChangeId(bottomRepo, bottomMasterHead).get();
 
     approve(changeId);
-
     exception.expectMessage("Branch level circular subscriptions detected");
     exception.expectMessage("top-project,refs/heads/master");
     exception.expectMessage("mid-project,refs/heads/master");
     exception.expectMessage("bottom-project,refs/heads/master");
-    gApi.changes().id(changeId).current().submit();
+    return changeId;
+  }
 
-    assertThat(hasSubmodule(midRepo, "master", "bottom-project")).isFalse();
-    assertThat(hasSubmodule(topRepo, "master", "mid-project")).isFalse();
+  @Test
+  public void testBranchCircularSubscription() throws Exception {
+    String changeId = prepareBranchCircularSubscription();
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  @Test
+  public void testBranchCircularSubscriptionPreview() throws Exception {
+    String changeId = prepareBranchCircularSubscription();
+    gApi.changes().id(changeId).current().submitPreview();
   }
 
   @Test
@@ -401,8 +551,8 @@
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("subscribed-to-project",
+        "refs/heads/master", "super-project", "refs/heads/master");
     allowMatchingSubmoduleSubscription("super-project", "refs/heads/dev",
         "subscribed-to-project", "refs/heads/dev");
 
@@ -433,4 +583,94 @@
         .isFalse();
     assertThat(hasSubmodule(subRepo, "dev", "super-project")).isFalse();
   }
+
+  @Test
+  public void testProjectNoSubscriptionWholeTopic() throws Exception {
+    TestRepository<?> repoA = createProjectWithPush("project-a");
+    TestRepository<?> repoB = createProjectWithPush("project-b");
+    // bootstrap the dev branch
+    ObjectId a0 = pushChangeTo(repoA, "dev");
+
+    // bootstrap the dev branch
+    ObjectId b0 = pushChangeTo(repoB, "dev");
+
+    // create a change for master branch in repo a
+    ObjectId aHead =
+        pushChangeTo(repoA, "refs/for/master", "master.txt", "content master A",
+            "some message in a master.txt", "same-topic");
+
+    // create a change for master branch in repo b
+    ObjectId bHead =
+        pushChangeTo(repoB, "refs/for/master", "master.txt", "content master B",
+            "some message in b master.txt", "same-topic");
+
+    // create a change for dev branch in repo a
+    repoA.reset(a0);
+    ObjectId aDevHead =
+        pushChangeTo(repoA, "refs/for/dev", "dev.txt", "content dev A",
+            "some message in a dev.txt", "same-topic");
+
+    // create a change for dev branch in repo b
+    repoB.reset(b0);
+    ObjectId bDevHead =
+        pushChangeTo(repoB, "refs/for/dev", "dev.txt", "content dev B",
+            "some message in b dev.txt", "same-topic");
+
+    approve(getChangeId(repoA, aHead).get());
+    approve(getChangeId(repoB, bHead).get());
+    approve(getChangeId(repoA, aDevHead).get());
+    approve(getChangeId(repoB, bDevHead).get());
+
+    gApi.changes().id(getChangeId(repoA, aDevHead).get()).current().submit();
+    assertThat(
+        getRemoteHead(name("project-a"), "refs/heads/master").getShortMessage())
+        .contains("some message in a master.txt");
+    assertThat(
+        getRemoteHead(name("project-a"), "refs/heads/dev").getShortMessage())
+        .contains("some message in a dev.txt");
+    assertThat(
+        getRemoteHead(name("project-b"), "refs/heads/master").getShortMessage())
+        .contains("some message in b master.txt");
+    assertThat(
+        getRemoteHead(name("project-b"), "refs/heads/dev").getShortMessage())
+        .contains("some message in b dev.txt");
+  }
+
+  @Test
+  public void testTwoProjectsMultipleBranchesWholeTopic() throws Exception {
+    TestRepository<?> repoA = createProjectWithPush("project-a");
+    TestRepository<?> repoB = createProjectWithPush("project-b");
+    // bootstrap the dev branch
+    pushChangeTo(repoA, "dev");
+
+    // bootstrap the dev branch
+    ObjectId b0 = pushChangeTo(repoB, "dev");
+
+    allowMatchingSubmoduleSubscription("project-b",
+        "refs/heads/master", "project-a", "refs/heads/master");
+    allowMatchingSubmoduleSubscription("project-b", "refs/heads/dev",
+        "project-a", "refs/heads/dev");
+
+    createSubmoduleSubscription(repoA, "master", "project-b", "master");
+    createSubmoduleSubscription(repoA, "dev", "project-b", "dev");
+
+
+    // create a change for master branch in repo b
+    ObjectId bHead =
+        pushChangeTo(repoB, "refs/for/master", "master.txt", "content master B",
+            "some message in b master.txt", "same-topic");
+
+    // create a change for dev branch in repo b
+    repoB.reset(b0);
+    ObjectId bDevHead =
+        pushChangeTo(repoB, "refs/for/dev", "dev.txt", "content dev B",
+            "some message in b dev.txt", "same-topic");
+
+    approve(getChangeId(repoB, bHead).get());
+    approve(getChangeId(repoB, bDevHead).get());
+    gApi.changes().id(getChangeId(repoB, bHead).get()).current().submit();
+
+    expectToHaveSubmoduleState(repoA, "master", "project-b", repoB, "master");
+    expectToHaveSubmoduleState(repoA, "dev", "project-b", repoB, "dev");
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUCK
index ff167ac..3522991 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUCK
@@ -3,6 +3,5 @@
 acceptance_tests(
   group = 'pgm',
   srcs = glob(['*IT.java']),
-  source_under_test = ['//gerrit-pgm:pgm'],
   labels = ['pgm'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD
index 806acd2..56cecb8 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD
@@ -3,6 +3,5 @@
 acceptance_tests(
   group = 'pgm',
   srcs = glob(['*IT.java']),
-  source_under_test = ['//gerrit-pgm:pgm'],
   labels = ['pgm'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/AccountAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/AccountAssert.java
index 91ee332..787902e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/AccountAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/AccountAssert.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -36,13 +35,7 @@
       List<AccountInfo> actual) {
     Iterable<Account.Id> expectedIds = TestAccount.ids(expected);
     Iterable<Account.Id> actualIds = Iterables.transform(
-        actual,
-        new Function<AccountInfo, Account.Id>() {
-          @Override
-          public Account.Id apply(AccountInfo in) {
-            return new Account.Id(in._accountId);
-          }
-        });
+        actual, a -> new Account.Id(a._accountId));
     assertThat(actualIds).containsExactlyElementsIn(expectedIds).inOrder();
     for (int i = 0; i < expected.size(); i++) {
       AccountAssert.assertAccountInfo(expected.get(i), actual.get(i));
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
index ce82270..116a3ac 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
@@ -26,7 +26,6 @@
 import static com.google.gerrit.common.data.GlobalCapability.RUN_AS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
-import com.google.common.base.Predicate;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
@@ -40,13 +39,9 @@
 
   @Test
   public void testCapabilitiesUser() throws Exception {
-    Iterable<String> all = Iterables.filter(GlobalCapability.getAllNames(),
-        new Predicate<String>() {
-          @Override
-          public boolean apply(String in) {
-            return !ADMINISTRATE_SERVER.equals(in) && !PRIORITY.equals(in);
-          }
-        });
+    Iterable<String> all = Iterables.filter(
+        GlobalCapability.getAllNames(),
+        c -> !ADMINISTRATE_SERVER.equals(c) && !PRIORITY.equals(c));
 
     allowGlobalCapabilities(REGISTERED_USERS, all);
     try {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
new file mode 100644
index 0000000..c1f4237
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -0,0 +1,656 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.RestSession;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+
+import org.apache.http.Header;
+import org.apache.http.message.BasicHeader;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ImpersonationIT extends AbstractDaemonTest {
+  @Inject
+  private AccountControl.Factory accountControlFactory;
+
+  @Inject
+  private ApprovalsUtil approvalsUtil;
+
+  @Inject
+  private ChangeMessagesUtil cmUtil;
+
+  @Inject
+  private CommentsUtil commentsUtil;
+
+  private RestSession anonRestSession;
+  private TestAccount admin2;
+  private GroupInfo newGroup;
+
+  @Before
+  public void setUp() throws Exception {
+    anonRestSession = new RestSession(server, null);
+    admin2 = accounts.admin2();
+    GroupInput gi = new GroupInput();
+    gi.name = name("New-Group");
+    gi.members = ImmutableList.of(user.id.toString());
+    newGroup = gApi.groups().create(gi).get();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    removeRunAs();
+  }
+
+  @Test
+  public void voteOnBehalfOf() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(
+        r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(user.id);
+    assertThat(m.getRealAuthor()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void voteOnBehalfOfRequiresLabel() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+
+    exception.expect(AuthException.class);
+    exception.expectMessage(
+        "label required to post review on behalf of \"" + in.onBehalfOf + '"');
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfInvalidLabel() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.strictLabels = true;
+    in.label("Not-A-Label", 5);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "label \"Not-A-Label\" is not a configured label");
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfInvalidLabelIgnoredWithoutStrictLabels()
+      throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.strictLabels = false;
+    in.label("Code-Review", 1);
+    in.label("Not-A-Label", 5);
+
+    revision.review(in);
+
+    assertThat(gApi.changes().id(r.getChangeId()).get().labels)
+        .doesNotContainKey("Not-A-Label");
+  }
+
+  @Test
+  public void voteOnBehalfOfLabelNotPermitted() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType verified = Util.verified();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Verified", 1);
+
+    exception.expect(AuthException.class);
+    exception.expectMessage(
+        "not permitted to modify label \"Verified\" on behalf of \""
+            + in.onBehalfOf + '"');
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfWithComment() throws Exception {
+    testVoteOnBehalfOfWithComment();
+  }
+
+  @GerritConfig(name = "notedb.writeJson", value = "true")
+  @Test
+  public void voteOnBehalfOfWithCommentWritingJson() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    testVoteOnBehalfOfWithComment();
+  }
+
+  private void testVoteOnBehalfOfWithComment() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    CommentInput ci = new CommentInput();
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "message";
+    in.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(
+        r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
+
+    ChangeData cd = r.getChange();
+    Comment c = Iterables.getOnlyElement(
+        commentsUtil.publishedByChange(db, cd.notes()));
+    assertThat(c.message).isEqualTo(ci.message);
+    assertThat(c.author.getId()).isEqualTo(user.id);
+    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
+  }
+
+  @GerritConfig(name = "notedb.writeJson", value = "true")
+  @Test
+  public void voteOnBehalfOfWithRobotComment() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    RobotCommentInput ci = new RobotCommentInput();
+    ci.robotId = "my-robot";
+    ci.robotRunId = "abcd1234";
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "message";
+    in.robotComments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    ChangeData cd = r.getChange();
+    RobotComment c = Iterables.getOnlyElement(
+        commentsUtil.robotCommentsByChange(cd.notes()));
+    assertThat(c.message).isEqualTo(ci.message);
+    assertThat(c.robotId).isEqualTo(ci.robotId);
+    assertThat(c.robotRunId).isEqualTo(ci.robotRunId);
+    assertThat(c.author.getId()).isEqualTo(user.id);
+    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void voteOnBehalfOfCannotModifyDrafts() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "message";
+    gApi.changes().id(r.getChangeId()).current().createDraft(di);
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    in.drafts = DraftHandling.PUBLISH;
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to modify other user's drafts");
+    gApi.changes().id(r.getChangeId()).current().review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfMissingUser() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = "doesnotexist";
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: doesnotexist");
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfFailsWhenUserCannotSeeDestinationRef()
+      throws Exception {
+    blockRead(newGroup);
+
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage(
+        "on_behalf_of account " + user.id + " cannot see destination ref");
+    revision.review(in);
+  }
+
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @Test
+  public void voteOnBehalfOfInvisibleUserNotAllowed() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    setApiUser(accounts.user2());
+    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: " + in.onBehalfOf);
+    revision.review(in);
+  }
+
+  @Test
+  public void submitOnBehalfOf() throws Exception {
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = admin2.email;
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .submit(in);
+
+    ChangeData cd = r.getChange();
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    PatchSetApproval submitter = approvalsUtil.getSubmitter(
+        db, cd.notes(), cd.change().currentPatchSetId());
+    assertThat(submitter.getAccountId()).isEqualTo(admin2.id);
+    assertThat(submitter.getRealAccountId()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void submitOnBehalfOfInvalidUser() throws Exception {
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = "doesnotexist";
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: doesnotexist");
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .submit(in);
+  }
+
+  @Test
+  public void submitOnBehalfOfNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(project.get() + "~master~" + r.getChangeId())
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = admin2.email;
+    exception.expect(AuthException.class);
+    exception.expectMessage("submit on behalf of not permitted");
+    gApi.changes()
+        .id(project.get() + "~master~" + r.getChangeId())
+        .current()
+        .submit(in);
+  }
+
+  @Test
+  public void submitOnBehalfOfFailsWhenUserCannotSeeDestinationRef()
+      throws Exception {
+    blockRead(newGroup);
+
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = user.email;
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage(
+        "on_behalf_of account " + user.id + " cannot see destination ref");
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .submit(in);
+  }
+
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @Test
+  public void submitOnBehalfOfInvisibleUserNotAllowed() throws Exception {
+    allowSubmitOnBehalfOf();
+    setApiUser(accounts.user2());
+    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = user.email;
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: " + in.onBehalfOf);
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .submit(in);
+  }
+
+  @Test
+  public void runAsValidUser() throws Exception {
+    allowRunAs();
+    RestResponse res =
+        adminRestSession.getWithHeader("/accounts/self", runAsHeader(user.id));
+    res.assertOK();
+    AccountInfo account =
+        newGson().fromJson(res.getEntityContent(), AccountInfo.class);
+    assertThat(account._accountId).isEqualTo(user.id.get());
+  }
+
+  @GerritConfig(name = "auth.enableRunAs", value = "false")
+  @Test
+  public void runAsDisabledByConfig() throws Exception {
+    allowRunAs();
+    RestResponse res =
+        adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("X-Gerrit-RunAs disabled by auth.enableRunAs = false");
+  }
+
+  @Test
+  public void runAsNotPermitted() throws Exception {
+    RestResponse res =
+        adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("not permitted to use X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void runAsNeverPermittedForAnonymousUsers() throws Exception {
+    allowRunAs();
+    RestResponse res =
+        anonRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("not permitted to use X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void runAsInvalidUser() throws Exception {
+    allowRunAs();
+    RestResponse res = adminRestSession.getWithHeader(
+        "/changes/", runAsHeader("doesnotexist"));
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("no account matches X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void voteUsingRunAsAvoidsRestrictionsOfOnBehalfOf() throws Exception {
+    allowRunAs();
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "inline comment";
+    gApi.changes().id(r.getChangeId()).current().createDraft(di);
+    setApiUser(admin);
+
+    // Things that aren't allowed with on_behalf_of:
+    //  - no labels.
+    //  - publish other user's drafts.
+    ReviewInput in = new ReviewInput();
+    in.message = "message";
+    in.drafts = DraftHandling.PUBLISH;
+    RestResponse res = adminRestSession.postWithHeader(
+        "/changes/" + r.getChangeId() + "/revisions/current/review", in,
+        runAsHeader(user.id));
+    res.assertOK();
+
+    ChangeMessageInfo m = Iterables.getLast(
+        gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(m.message).endsWith(in.message);
+    assertThat(m.author._accountId).isEqualTo(user.id.get());
+
+    CommentInfo c = Iterables.getOnlyElement(
+        gApi.changes().id(r.getChangeId()).comments().get(di.path));
+    assertThat(c.author._accountId).isEqualTo(user.id.get());
+    assertThat(c.message).isEqualTo(di.message);
+
+    setApiUser(user);
+    assertThat(gApi.changes().id(r.getChangeId()).drafts()).isEmpty();
+  }
+
+  @Test
+  public void runAsWithOnBehalfOf() throws Exception {
+    // - Has the same restrictions as on_behalf_of (e.g. requires labels).
+    // - Takes the effective user from on_behalf_of (user).
+    // - Takes the real user from the real caller, not the intermediate
+    //   X-Gerrit-RunAs user (user2).
+    allowRunAs();
+    allowCodeReviewOnBehalfOf();
+    TestAccount user2 = accounts.user2();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+
+    String endpoint =
+        "/changes/" + r.getChangeId() + "/revisions/current/review";
+    RestResponse res =
+        adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent()).isEqualTo(
+        "label required to post review on behalf of \"" + in.onBehalfOf + '"');
+
+    in.label("Code-Review", 1);
+    adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id))
+        .assertOK();
+
+    PatchSetApproval psa = Iterables.getOnlyElement(
+        r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id); // not user2
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(user.id);
+    assertThat(m.getRealAuthor()).isEqualTo(admin.id); // not user2
+  }
+
+  private void allowCodeReviewOnBehalfOf() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType codeReviewType = Util.codeReview();
+    String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
+    String heads = "refs/heads/*";
+    AccountGroup.UUID uuid =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(cfg, forCodeReviewAs, -1, 1, uuid, heads);
+    saveProjectConfig(project, cfg);
+  }
+
+  private void allowSubmitOnBehalfOf() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    String heads = "refs/heads/*";
+    AccountGroup.UUID uuid =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(cfg, Permission.SUBMIT_AS, uuid, heads);
+    Util.allow(cfg, Permission.SUBMIT, uuid, heads);
+    LabelType codeReviewType = Util.codeReview();
+    Util.allow(cfg, Permission.forLabel(codeReviewType.getName()),
+        -2, 2, uuid, heads);
+    saveProjectConfig(project, cfg);
+  }
+
+  private void blockRead(GroupInfo group) throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.block(
+        cfg, Permission.READ, new AccountGroup.UUID(group.id), "refs/heads/master");
+    saveProjectConfig(project, cfg);
+  }
+
+  private void allowRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.allow(cfg, GlobalCapability.RUN_AS,
+        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private void removeRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.remove(cfg, GlobalCapability.RUN_AS,
+        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private static Header runAsHeader(Object user) {
+    return new BasicHeader("X-Gerrit-RunAs", user.toString());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index c9c81df..9927c15 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -20,16 +20,19 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
+import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -37,9 +40,9 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -52,12 +55,15 @@
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.Submit;
+import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.Util;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -65,13 +71,16 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 import java.io.ByteArrayOutputStream;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 @NoHttpd
 public abstract class AbstractSubmit extends AbstractDaemonTest {
@@ -110,9 +119,231 @@
   @Test
   @TestProjectInput(createEmptyCommit = false)
   public void submitToEmptyRepo() throws Exception {
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
+    BinaryResult request = submitPreview(change.getChangeId());
+    RevCommit headAfterSubmitPreview = getRemoteHead();
+    assertThat(headAfterSubmitPreview).isEqualTo(initialHead);
+    Map<Branch.NameKey, RevTree> actual =
+        fetchFromBundles(request);
+    assertThat(actual).hasSize(1);
+
     submit(change.getChangeId());
     assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertRevTrees(project, actual);
+  }
+
+  @Test
+  public void submitSingleChange() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange();
+    BinaryResult request = submitPreview(change.getChangeId());
+    RevCommit headAfterSubmit = getRemoteHead();
+    assertThat(headAfterSubmit).isEqualTo(initialHead);
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+
+    Map<Branch.NameKey, RevTree> actual =
+        fetchFromBundles(request);
+
+    if ((getSubmitType() == SubmitType.CHERRY_PICK)
+        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
+      // The change is updated as well:
+      assertThat(actual).hasSize(2);
+    } else {
+      assertThat(actual).hasSize(1);
+    }
+
+    submit(change.getChangeId());
+    assertRevTrees(project, actual);
+  }
+
+  @Test
+  public void submitMultipleChangesOtherMergeConflictPreview()
+      throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    PushOneCommit.Result change =
+        createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2",
+        "a.txt", "other content");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
+    // change 2 is not approved, but we ignore labels
+    approve(change3.getChangeId());
+    BinaryResult request = null;
+    String msg = null;
+    try {
+      request = submitPreview(change4.getChangeId());
+    } catch (Exception e) {
+      msg = e.getMessage();
+    }
+
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      Map<Branch.NameKey, RevTree> s =
+          fetchFromBundles(request);
+      submit(change4.getChangeId());
+      assertRevTrees(project, s);
+    } else if (getSubmitType() == SubmitType.FAST_FORWARD_ONLY) {
+      assertThat(msg).isEqualTo(
+          "Failed to submit 3 changes due to the following problems:\n" +
+          "Change " + change2.getChange().getId() + ": internal error: " +
+              "change not processed by merge strategy\n" +
+          "Change " + change3.getChange().getId() + ": internal error: " +
+              "change not processed by merge strategy\n" +
+          "Change " + change4.getChange().getId() + ": Project policy " +
+              "requires all submissions to be a fast-forward. Please " +
+              "rebase the change locally and upload again for review.");
+      RevCommit headAfterSubmit = getRemoteHead();
+      assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
+      assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+      assertChangeMergedEvents(change.getChangeId(),
+          headAfterFirstSubmit.name());
+    } else if ((getSubmitType() == SubmitType.REBASE_IF_NECESSARY)
+        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
+      String change2hash = change2.getChange().currentPatchSet()
+          .getRevision().get();
+      assertThat(msg).isEqualTo(
+          "Cannot rebase " + change2hash + ": The change could " +
+          "not be rebased due to a conflict during merge.");
+      RevCommit headAfterSubmit = getRemoteHead();
+      assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
+      assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+      assertChangeMergedEvents(change.getChangeId(),
+          headAfterFirstSubmit.name());
+    } else {
+      assertThat(msg).isEqualTo(
+          "Failed to submit 3 changes due to the following problems:\n" +
+          "Change " + change2.getChange().getId() + ": Change could not be " +
+              "merged due to a path conflict. Please rebase the change " +
+              "locally and upload the rebased commit for review.\n" +
+          "Change " + change3.getChange().getId() + ": Change could not be " +
+              "merged due to a path conflict. Please rebase the change " +
+              "locally and upload the rebased commit for review.\n" +
+          "Change " + change4.getChange().getId() + ": Change could not be " +
+              "merged due to a path conflict. Please rebase the change " +
+              "locally and upload the rebased commit for review.");
+      RevCommit headAfterSubmit = getRemoteHead();
+      assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
+      assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+      assertChangeMergedEvents(change.getChangeId(),
+          headAfterFirstSubmit.name());
+    }
+  }
+
+  @Test
+  public void submitMultipleChangesPreview() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change2 = createChange("Change 2",
+        "a.txt", "other content");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
+    // change 2 is not approved, but we ignore labels
+    approve(change3.getChangeId());
+    BinaryResult request = submitPreview(change4.getChangeId());
+
+    Map<String, Map<String, Integer>> expected = new HashMap<>();
+    expected.put(project.get(), new HashMap<String, Integer>());
+    expected.get(project.get()).put("refs/heads/master", 3);
+    Map<Branch.NameKey, RevTree> actual =
+        fetchFromBundles(request);
+
+    assertThat(actual).containsKey(
+        new Branch.NameKey(project, "refs/heads/master"));
+    if (getSubmitType() == SubmitType.CHERRY_PICK){
+      // CherryPick ignores dependencies, thus only change and destination
+      // branch refs are modified.
+      assertThat(actual).hasSize(2);
+    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      // RebaseAlways takes care of dependencies, therefore Change{2,3,4} and
+      // destination branch will be modified.
+      assertThat(actual).hasSize(4);
+    } else {
+      assertThat(actual).hasSize(1);
+    }
+
+    // check that the submit preview did not actually submit
+    RevCommit headAfterSubmit = getRemoteHead();
+    assertThat(headAfterSubmit).isEqualTo(initialHead);
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+
+    // now check we actually have the same content:
+    approve(change2.getChangeId());
+    submit(change4.getChangeId());
+    assertRevTrees(project, actual);
+  }
+
+  @Test
+  public void submitNoPermission() throws Exception {
+    // create project where submit is blocked
+    Project.NameKey p = createProject("p");
+    block(Permission.SUBMIT, REGISTERED_USERS, "refs/*", p);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    submit(result.getChangeId(), new SubmitInput(), AuthException.class,
+        "submit not permitted");
+  }
+
+  @Test
+  public void noSelfSubmit() throws Exception {
+    // create project where submit is blocked for the change owner
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.block(cfg, Permission.SUBMIT, CHANGE_OWNER, "refs/*");
+    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2,
+        REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+
+    submit(result.getChangeId(), new SubmitInput(), AuthException.class,
+        "submit not permitted");
+
+    setApiUser(user);
+    submit(result.getChangeId());
+  }
+
+  @Test
+  public void onlySelfSubmit() throws Exception {
+    // create project where only the change owner can submit
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.block(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/*");
+    Util.allow(cfg, Permission.SUBMIT, CHANGE_OWNER, "refs/*");
+    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2,
+        REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+
+    setApiUser(user);
+    submit(result.getChangeId(), new SubmitInput(), AuthException.class,
+        "submit not permitted");
+
+    setApiUser(admin);
+    submit(result.getChangeId());
   }
 
   @Test
@@ -179,18 +410,15 @@
   private void assertSubmitter(PushOneCommit.Result change) throws Exception {
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
-    Iterable<String> messages = Iterables.transform(info.messages,
-        new Function<ChangeMessageInfo, String>() {
-          @Override
-          public String apply(ChangeMessageInfo in) {
-            return in.message;
-          }
-        });
+    Iterable<String> messages =
+        Iterables.transform(info.messages, i -> i.message);
     assertThat(messages).hasSize(3);
     String last = Iterables.getLast(messages);
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
       assertThat(last).startsWith(
           "Change has been successfully cherry-picked as ");
+    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertThat(last).startsWith("Change has been successfully rebased as");
     } else {
       assertThat(last).isEqualTo(
           "Change has been successfully merged by Administrator");
@@ -252,8 +480,12 @@
     assertMerged(change.changeId);
   }
 
+  protected BinaryResult submitPreview(String changeId) throws Exception {
+    return gApi.changes().id(changeId).current().submitPreview();
+  }
+
   protected void assertSubmittable(String changeId) throws Exception {
-    assertThat(gApi.changes().id(changeId).info().submittable)
+    assertThat(get(changeId, SUBMITTABLE).submittable)
         .named("submit bit on ChangeInfo")
         .isEqualTo(true);
     RevisionResource rsrc = parseCurrentRevisionResource(changeId);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
new file mode 100644
index 0000000..492fc05
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -0,0 +1,353 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.getChangeId;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.Submit.TestSubmitInput;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Test;
+
+public abstract class AbstractSubmitByRebase extends AbstractSubmit {
+
+  @Override
+  protected abstract SubmitType getSubmitType();
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithRebase() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change =
+        createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 =
+        createChange("Change 2", "b.txt", "other content");
+    submit(change2.getChangeId());
+    assertRebase(testRepo, false);
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit.getParent(0))
+        .isEqualTo(headAfterFirstSubmit);
+    assertApproved(change2.getChangeId());
+    assertCurrentRevision(change2.getChangeId(), 2, headAfterSecondSubmit);
+    assertSubmitter(change2.getChangeId(), 1);
+    assertSubmitter(change2.getChangeId(), 2);
+    assertPersonEquals(admin.getIdent(),
+        headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(),
+        headAfterSecondSubmit.getCommitterIdent());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void submitWithRebaseMultipleChanges() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 =
+        createChange("Change 1", "a.txt", "content");
+    submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertCurrentRevision(change1.getChangeId(), 2, headAfterFirstSubmit);
+    } else {
+      assertThat(headAfterFirstSubmit.name())
+          .isEqualTo(change1.getCommit().name());
+    }
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 =
+        createChange("Change 2", "b.txt", "other content");
+    assertThat(change2.getCommit().getParent(0))
+        .isNotEqualTo(change1.getCommit());
+    PushOneCommit.Result change3 =
+        createChange("Change 3", "c.txt", "third content");
+    PushOneCommit.Result change4 =
+        createChange("Change 4", "d.txt", "fourth content");
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    submit(change4.getChangeId());
+
+    assertRebase(testRepo, false);
+    assertApproved(change2.getChangeId());
+    assertApproved(change3.getChangeId());
+    assertApproved(change4.getChangeId());
+
+    RevCommit headAfterSecondSubmit = parse(getRemoteHead());
+    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo("Change 4");
+    assertThat(headAfterSecondSubmit).isNotEqualTo(change4.getCommit());
+    assertCurrentRevision(change4.getChangeId(), 2, headAfterSecondSubmit);
+
+    RevCommit parent = parse(headAfterSecondSubmit.getParent(0));
+    assertThat(parent.getShortMessage()).isEqualTo("Change 3");
+    assertThat(parent).isNotEqualTo(change3.getCommit());
+    assertCurrentRevision(change3.getChangeId(), 2, parent);
+
+    RevCommit grandparent = parse(parent.getParent(0));
+    assertThat(grandparent).isNotEqualTo(change2.getCommit());
+    assertCurrentRevision(change2.getChangeId(), 2, grandparent);
+
+    RevCommit greatgrandparent = parse(grandparent.getParent(0));
+    assertThat(greatgrandparent).isEqualTo(headAfterFirstSubmit);
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertCurrentRevision(change1.getChangeId(), 2, greatgrandparent);
+    } else {
+      assertCurrentRevision(change1.getChangeId(), 1, greatgrandparent);
+    }
+
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name(),
+        change3.getChangeId(), headAfterSecondSubmit.name(),
+        change4.getChangeId(), headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void submitWithRebaseMergeCommit() throws Exception {
+    /*
+        *  (HEAD, origin/master, origin/HEAD) Merge changes X,Y
+        |\
+        | *   Merge branch 'master' into origin/master
+        | |\
+        | | * SHA Added a
+        | |/
+        * | Before
+        |/
+        * Initial empty repository
+     */
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
+
+    PushOneCommit change2Push = pushFactory.create(db, admin.getIdent(), testRepo,
+        "Merge to master", "m.txt", "");
+    change2Push.setParents(ImmutableList.of(initialHead, change1.getCommit()));
+    PushOneCommit.Result change2 = change2Push.to("refs/for/master");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change3 = createChange("Before", "b.txt", "");
+
+    approve(change3.getChangeId());
+    submit(change3.getChangeId());
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    submit(change2.getChangeId());
+
+    RevCommit newHead = getRemoteHead();
+    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(initialHead.getId());
+    assertThat(headGrandparent2.getId()).isEqualTo(change1.getCommit().getId());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithContentMerge_Conflict() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change =
+        createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 =
+        createChange("Change 2", "a.txt", "other content");
+    submitWithConflict(change2.getChangeId(),
+        "Cannot rebase " + change2.getCommit().name()
+        + ": The change could not be rebased due to a conflict during merge.");
+    RevCommit head = getRemoteHead();
+    assertThat(head).isEqualTo(headAfterFirstSubmit);
+    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
+    assertNoSubmitter(change2.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
+  }
+
+  @Test
+  public void repairChangeStateAfterFailure() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change =
+        createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 =
+        createChange("Change 2", "b.txt", "other content");
+    Change.Id id2 = change2.getChange().getId();
+    SubmitInput failAfterRefUpdates =
+        new TestSubmitInput(new SubmitInput(), true);
+    submit(change2.getChangeId(), failAfterRefUpdates,
+        ResourceConflictException.class, "Failing after ref updates");
+    RevCommit headAfterFailedSubmit = getRemoteHead();
+
+    // Bad: ref advanced but change wasn't updated.
+    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
+    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
+    ChangeInfo info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+    assertThat(getPatchSet(psId2)).isNull();
+
+    ObjectId rev2;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
+      assertThat(rev1).isNotNull();
+
+      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
+      assertThat(rev2).isNotNull();
+      assertThat(rev2).isNotEqualTo(rev1);
+      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
+
+      assertThat(repo.exactRef("refs/heads/master").getObjectId())
+          .isEqualTo(rev2);
+    }
+
+    submit(change2.getChangeId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
+
+    // Change status and patch set entities were updated, and branch tip stayed
+    // the same.
+    info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
+    PatchSet ps2 = getPatchSet(psId2);
+    assertThat(ps2).isNotNull();
+    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo("Change has been successfully rebased as "
+            + rev2.name() + " by Administrator");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef("refs/heads/master").getObjectId())
+          .isEqualTo(rev2);
+    }
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name());
+  }
+
+  protected RevCommit parse(ObjectId id) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = rw.parseCommit(id);
+      rw.parseBody(c);
+      return c;
+    }
+  }
+
+  @Test
+  public void submitAfterReorderOfCommits() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    // Create two commits and push.
+    RevCommit c1 = commitBuilder()
+        .add("a.txt", "1")
+        .message("subject: 1")
+        .create();
+    RevCommit c2 = commitBuilder()
+        .add("b.txt", "2")
+        .message("subject: 2")
+        .create();
+    pushHead(testRepo, "refs/for/master", false);
+
+    String id1 = getChangeId(testRepo, c1).get();
+    String id2 = getChangeId(testRepo, c2).get();
+
+    // Swap the order of commits and push again.
+    testRepo.reset("HEAD~2");
+    testRepo.cherryPick(c2);
+    testRepo.cherryPick(c1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    approve(id1);
+    approve(id2);
+    submit(id1);
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    assertRefUpdatedEvents(initialHead, headAfterSubmit);
+    assertChangeMergedEvents(id2, headAfterSubmit.name(),
+        id1, headAfterSubmit.name());
+  }
+
+  @Test
+  public void submitChangesAfterBranchOnSecond() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    PushOneCommit.Result change = createChange();
+    approve(change.getChangeId());
+
+    PushOneCommit.Result change2 = createChange();
+    approve(change2.getChangeId());
+    Project.NameKey project = change2.getChange().change().getProject();
+    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    createBranchWithRevision(branch, change2.getCommit().getName());
+    gApi.changes().id(change2.getChangeId()).current().submit();
+    assertMerged(change2.getChangeId());
+    assertMerged(change.getChangeId());
+
+    RevCommit newHead = getRemoteHead();
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name(),
+        change2.getChangeId(), newHead.name());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
new file mode 100644
index 0000000..685cceb
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.testutil.TestTimeUtil;
+
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.Iterator;
+import java.util.List;
+
+@NoHttpd
+public class AssigneeIT extends AbstractDaemonTest {
+
+  @BeforeClass
+  public static void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @AfterClass
+  public static void restoreTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void testGetNoAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(getAssignee(r)).isNull();
+  }
+
+  @Test
+  public void testAddGetAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(setAssignee(r, user.email)._accountId)
+        .isEqualTo(user.getId().get());
+    assertThat(getAssignee(r)._accountId).isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void testSetNewAssigneeWhenExists() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setAssignee(r, user.email);
+    assertThat(setAssignee(r, user.email)._accountId)
+    .isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void testGetPastAssignees() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+    setAssignee(r, user.email);
+    setAssignee(r, admin.email);
+    List<AccountInfo> assignees = getPastAssignees(r);
+    assertThat(assignees).hasSize(2);
+    Iterator<AccountInfo> itr = assignees.iterator();
+    assertThat(itr.next()._accountId).isEqualTo(user.getId().get());
+    assertThat(itr.next()._accountId).isEqualTo(admin.getId().get());
+  }
+
+  @Test
+  public void testAssigneeAddedAsReviewer() throws Exception {
+    ReviewerState state;
+    // Assignee is added as CC, if back-end is reviewDb (that does not support
+    // CC) CC is stored as REVIEWER
+    if (notesMigration.readChanges()) {
+      state = ReviewerState.CC;
+    } else {
+      state = ReviewerState.REVIEWER;
+    }
+    PushOneCommit.Result r = createChange();
+    Iterable<AccountInfo> reviewers = getReviewers(r, state);
+    assertThat(reviewers).isNull();
+    assertThat(setAssignee(r, user.email)._accountId)
+        .isEqualTo(user.getId().get());
+    reviewers = getReviewers(r, state);
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getFirst(reviewers, null);
+    assertThat(reviewer._accountId).isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void testSetAlreadyExistingAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setAssignee(r, user.email);
+    assertThat(setAssignee(r, user.email)._accountId)
+        .isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void testDeleteAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(setAssignee(r, user.email)._accountId)
+        .isEqualTo(user.getId().get());
+    assertThat(deleteAssignee(r)._accountId).isEqualTo(user.getId().get());
+    assertThat(getAssignee(r)).isNull();
+  }
+
+  @Test
+  public void testDeleteAssigneeWhenNoAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(deleteAssignee(r)).isNull();
+  }
+
+  private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
+    return gApi.changes().id(r.getChange().getId().get()).getAssignee();
+  }
+
+  private List<AccountInfo> getPastAssignees(PushOneCommit.Result r)
+      throws Exception {
+    return gApi.changes().id(r.getChange().getId().get()).getPastAssignees();
+  }
+
+  private Iterable<AccountInfo> getReviewers(PushOneCommit.Result r,
+      ReviewerState state) throws Exception {
+    return get(r.getChangeId()).reviewers.get(state);
+  }
+
+  private AccountInfo setAssignee(PushOneCommit.Result r, String identifieer)
+      throws Exception {
+    AssigneeInput input = new AssigneeInput();
+    input.assignee = identifieer;
+    return gApi.changes().id(r.getChange().getId().get()).setAssignee(input);
+  }
+
+  private AccountInfo deleteAssignee(PushOneCommit.Result r) throws Exception {
+    return gApi.changes().id(r.getChange().getId().get()).deleteAssignee();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
index 04e71eb..654ce29 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
@@ -1,10 +1,6 @@
 include_defs('//gerrit-acceptance-tests/tests.defs')
 
-SUBMIT_UTIL_SRCS = [
-  'AbstractSubmit.java',
-  'AbstractSubmitByMerge.java',
-]
-
+SUBMIT_UTIL_SRCS = glob(['AbstractSubmit*.java'])
 SUBMIT_TESTS = glob(['Submit*IT.java'])
 OTHER_TESTS = glob(['*IT.java'], excludes = SUBMIT_TESTS)
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
index c06f02f..6fbf9c5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
@@ -1,10 +1,6 @@
 load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
 
-SUBMIT_UTIL_SRCS = [
-  'AbstractSubmit.java',
-  'AbstractSubmitByMerge.java',
-]
-
+SUBMIT_UTIL_SRCS = glob(['AbstractSubmit*.java'])
 SUBMIT_TESTS = glob(['Submit*IT.java'])
 OTHER_TESTS = glob(['*IT.java'], exclude = SUBMIT_TESTS)
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index aa7e864..1ef6337 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -32,7 +32,9 @@
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.mail.Address;
@@ -43,8 +45,10 @@
 
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 
 public class ChangeReviewersIT extends AbstractDaemonTest {
   @Test
@@ -260,6 +264,165 @@
   }
 
   @Test
+  public void driveByComment() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // Post drive-by message as user.
+    ReviewInput input = new ReviewInput().message("hello");
+    RestResponse resp = userRestSession.post(
+        "/changes/" + r.getChangeId() + "/revisions/" +
+        r.getCommit().getName() + "/review", input);
+    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNull();
+
+    // Verify user is not added as reviewer.
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertReviewers(c, REVIEWER);
+    assertReviewers(c, CC);
+  }
+
+  @Test
+  public void addSelfAsReviewer() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // user adds self as REVIEWER.
+    ReviewInput input = new ReviewInput().reviewer(user.username);
+    RestResponse resp = userRestSession.post(
+        "/changes/" + r.getChangeId() + "/revisions/" +
+        r.getCommit().getName() + "/review", input);
+    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(1);
+
+    // Verify reviewer state.
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertReviewers(c, REVIEWER, user);
+    assertReviewers(c, CC);
+    LabelInfo label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNotNull();
+    assertThat(label.all).hasSize(1);
+    ApprovalInfo approval = label.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void addSelfAsCc() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // user adds self as CC.
+    ReviewInput input = new ReviewInput().reviewer(user.username, CC, false);
+    RestResponse resp = userRestSession.post(
+        "/changes/" + r.getChangeId() + "/revisions/" +
+        r.getCommit().getName() + "/review", input);
+    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(1);
+
+    // Verify reviewer state.
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER);
+      assertReviewers(c, CC, user);
+      // Verify no approvals were added.
+      assertThat(c.labels).isNotNull();
+      LabelInfo label = c.labels.get("Code-Review");
+      assertThat(label).isNotNull();
+      assertThat(label.all).isNull();
+    } else {
+      // When approvals are stored in ReviewDb, we still create a label for
+      // the reviewing user, and force them into the REVIEWER state.
+      assertReviewers(c, REVIEWER, user);
+      assertReviewers(c, CC);
+      LabelInfo label = c.labels.get("Code-Review");
+      assertThat(label).isNotNull();
+      assertThat(label.all).isNotNull();
+      assertThat(label.all).hasSize(1);
+      ApprovalInfo approval = label.all.get(0);
+      assertThat(approval._accountId).isEqualTo(user.getId().get());
+    }
+  }
+
+  @Test
+  public void reviewerReplyWithoutVote() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // Verify reviewer state.
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertReviewers(c, REVIEWER);
+    assertReviewers(c, CC);
+    LabelInfo label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNull();
+
+    // Add user as REVIEWER.
+    ReviewInput input = new ReviewInput().reviewer(user.username);
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(1);
+
+    // Verify reviewer state. Both admin and user should be REVIEWERs now,
+    // because admin gets forced into REVIEWER state by virtue of being owner.
+    c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertReviewers(c, REVIEWER, admin, user);
+    assertReviewers(c, CC);
+    label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNotNull();
+    assertThat(label.all).hasSize(2);
+    Map<Integer, Integer> approvals = new HashMap<>();
+    for (ApprovalInfo approval : label.all) {
+      approvals.put(approval._accountId, approval.value);
+    }
+    assertThat(approvals).containsEntry(admin.getId().get(), 0);
+    assertThat(approvals).containsEntry(user.getId().get(), 0);
+
+    // Comment as user without voting. This should delete the approval and
+    // then replace it with the default value.
+    input = new ReviewInput().message("hello");
+    RestResponse resp = userRestSession.post(
+        "/changes/" + r.getChangeId() + "/revisions/" +
+        r.getCommit().getName() + "/review", input);
+    result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+
+    // Verify reviewer state.
+    c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertReviewers(c, REVIEWER, admin, user);
+    assertReviewers(c, CC);
+    label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNotNull();
+    assertThat(label.all).hasSize(2);
+    approvals.clear();
+    for (ApprovalInfo approval : label.all) {
+      approvals.put(approval._accountId, approval.value);
+    }
+    assertThat(approvals).containsEntry(admin.getId().get(), 0);
+    assertThat(approvals).containsEntry(user.getId().get(), 0);
+  }
+
+  @Test
   public void reviewAndAddReviewers() throws Exception {
     TestAccount observer = accounts.user2();
     PushOneCommit.Result r = createChange();
@@ -288,35 +451,22 @@
 
     // Verify emails were sent to added reviewers.
     List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(3);
-    // First email to user.
+    assertThat(messages).hasSize(2);
+
     Message m = messages.get(0);
-    if (notesMigration.readChanges()) {
-      assertThat(m.rcpt()).containsExactly(user.emailAddress);
-    } else {
-      assertThat(m.rcpt()).containsExactly(
-          user.emailAddress, observer.emailAddress);
-    }
+    assertThat(m.rcpt())
+        .containsExactly(user.emailAddress,observer.emailAddress);
+    assertThat(m.body())
+        .contains(admin.fullName + " has posted comments on this change.");
+    assertThat(m.body())
+        .contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertThat(m.body()).contains("Patch Set 1: Code-Review+2");
+
+    m = messages.get(1);
+    assertThat(m.rcpt())
+        .containsExactly(user.emailAddress, observer.emailAddress);
     assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
-    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    // Second email to reviewer and observer.
-    m = messages.get(1);
-    if (notesMigration.readChanges()) {
-      assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
-      assertThat(m.body()).contains(admin.fullName + " has uploaded a new change for review.");
-    } else {
-      assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
-      assertThat(m.body()).contains("Hello " + observer.fullName + ",\n");
-      assertThat(m.body()).contains("I'd like you to do a code review.");
-    }
-
-    // Third email is review to user and observer.
-    m = messages.get(2);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
-    assertThat(m.body()).contains(admin.fullName + " has posted comments on this change.");
-    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertThat(m.body()).contains("Patch Set 1: Code-Review+2\n");
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java
new file mode 100644
index 0000000..f5ae072
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -0,0 +1,160 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.testutil.ConfigSuite;
+
+import org.apache.http.Header;
+import org.apache.http.client.fluent.Request;
+import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class CorsIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config allowExampleDotCom() {
+    Config cfg = new Config();
+    cfg.setStringList(
+        "site", null, "allowOriginRegex",
+        ImmutableList.of(
+            "https?://(.+[.])?example[.]com",
+            "http://friend[.]ly"));
+    return cfg;
+  }
+
+  @Test
+  public void origin() throws Exception {
+    Result change = createChange();
+
+    String url = "/changes/" + change.getChangeId() + "/detail";
+    RestResponse r = adminRestSession.get(url);
+    r.assertOK();
+    assertThat(r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN)).isNull();
+    assertThat(r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS)).isNull();
+
+    check(url, true, "http://example.com");
+    check(url, true, "https://sub.example.com");
+    check(url, true, "http://friend.ly");
+
+    check(url, false, "http://evil.attacker");
+    check(url, false, "http://friendsly");
+  }
+
+  @Test
+  public void putWithOriginRefused() throws Exception {
+    Result change = createChange();
+    String origin = "http://example.com";
+    RestResponse r = adminRestSession.putWithHeader(
+        "/changes/" + change.getChangeId() + "/topic",
+        new BasicHeader(ORIGIN, origin),
+        "A");
+    r.assertOK();
+    checkCors(r, false, origin);
+  }
+
+  @Test
+  public void preflightOk() throws Exception {
+    Result change = createChange();
+
+    String origin = "http://example.com";
+    Request req = Request.Options(adminRestSession.url()
+        + "/a/changes/" + change.getChangeId() + "/detail");
+    req.addHeader(ORIGIN, origin);
+    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
+    req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Requested-With");
+
+    RestResponse res = adminRestSession.execute(req);
+    res.assertOK();
+    checkCors(res, true, origin);
+  }
+
+  @Test
+  public void preflightBadOrigin() throws Exception {
+    Result change = createChange();
+
+    Request req = Request.Options(adminRestSession.url()
+        + "/a/changes/" + change.getChangeId() + "/detail");
+    req.addHeader(ORIGIN, "http://evil.attacker");
+    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
+
+    adminRestSession.execute(req).assertBadRequest();
+  }
+
+  @Test
+  public void preflightBadMethod() throws Exception {
+    Result change = createChange();
+
+    for (String method : new String[] {"POST", "PUT", "DELETE", "PATCH"}) {
+      Request req = Request.Options(adminRestSession.url()
+          + "/a/changes/" + change.getChangeId() + "/detail");
+      req.addHeader(ORIGIN, "http://example.com");
+      req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, method);
+      adminRestSession.execute(req).assertBadRequest();
+    }
+  }
+
+  @Test
+  public void preflightBadHeader() throws Exception {
+    Result change = createChange();
+
+    Request req = Request.Options(adminRestSession.url()
+        + "/a/changes/" + change.getChangeId() + "/detail");
+    req.addHeader(ORIGIN, "http://example.com");
+    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
+    req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Gerrit-Auth");
+
+    adminRestSession.execute(req).assertBadRequest();
+  }
+
+  private RestResponse check(String url, boolean accept, String origin)
+      throws Exception {
+    Header hdr = new BasicHeader(ORIGIN, origin);
+    RestResponse r = adminRestSession.getWithHeader(url, hdr);
+    r.assertOK();
+    checkCors(r, accept, origin);
+    return r;
+  }
+
+  private void checkCors(RestResponse r, boolean accept, String origin) {
+    String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
+    String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
+    String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
+    String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
+    if (accept) {
+      assertThat(allowOrigin).isEqualTo(origin);
+      assertThat(allowCred).isEqualTo("true");
+      assertThat(allowMethods).isEqualTo("GET, OPTIONS");
+      assertThat(allowHeaders).isEqualTo("X-Requested-With");
+    } else {
+      assertThat(allowOrigin).isNull();
+      assertThat(allowCred).isNull();
+      assertThat(allowMethods).isNull();
+      assertThat(allowHeaders).isNull();
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
index 31e52f7..2474d68 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
@@ -152,8 +152,8 @@
     for (ChangeMessage m : cd.messages()) {
       assertThat(m.getPatchSetId()).named(m.toString()).isNotEqualTo(delPsId);
     }
-    for (PatchLineComment c : cd.publishedComments()) {
-      assertThat(c.getPatchSetId()).named(c.toString()).isNotEqualTo(delPsId);
+    for (Comment c : cd.publishedComments()) {
+      assertThat(c.key.patchSetId).named(c.toString()).isNotEqualTo(delPsId.get());
     }
   }
 
@@ -187,8 +187,8 @@
     for (ChangeMessage m : cd.messages()) {
       assertThat(m.getPatchSetId()).named(m.toString()).isNotEqualTo(delPsId);
     }
-    for (PatchLineComment c : cd.publishedComments()) {
-      assertThat(c.getPatchSetId()).named(c.toString()).isNotEqualTo(delPsId);
+    for (Comment c : cd.publishedComments()) {
+      assertThat(c.key.patchSetId).named(c.toString()).isNotEqualTo(delPsId.get());
     }
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
index 2a32abe..eb6e433 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
@@ -22,20 +22,35 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.notedb.PatchSetState;
 import com.google.gerrit.testutil.ConfigSuite;
+import com.google.inject.Inject;
 
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
 import java.util.Collection;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.stream.Collectors;
 
 public class DraftChangeIT extends AbstractDaemonTest {
   @ConfigSuite.Config
@@ -43,20 +58,8 @@
     return allowDraftsDisabledConfig();
   }
 
-  @Test
-  public void deleteChange() throws Exception {
-    PushOneCommit.Result result = createChange();
-    result.assertOkStatus();
-    String changeId = result.getChangeId();
-    String triplet = project.get() + "~master~" + changeId;
-    ChangeInfo c = get(triplet);
-    assertThat(c.id).isEqualTo(triplet);
-    assertThat(c.status).isEqualTo(ChangeStatus.NEW);
-    RestResponse response = deleteChange(changeId, adminRestSession);
-    assertThat(response.getEntityContent())
-        .isEqualTo("Change is not a draft: " + c._number);
-    response.assertConflict();
-  }
+  @Inject
+  private BatchUpdate.Factory updateFactory;
 
   @Test
   public void deleteDraftChange() throws Exception {
@@ -75,6 +78,104 @@
   }
 
   @Test
+  public void deleteDraftChangeOfAnotherUser() throws Exception {
+    assume().that(isAllowDrafts()).isTrue();
+    PushOneCommit.Result changeResult = createDraftChange();
+    changeResult.assertOkStatus();
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    // The user needs to be able to see the draft change (which reviewers can).
+    gApi.changes()
+        .id(changeId)
+        .addReviewer(user.fullName);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage(String.format(
+        "Deleting change %s is not permitted", id));
+    gApi.changes()
+        .id(changeId)
+        .delete();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteDraftChangeWhenDraftsNotAllowedAsNormalUser()
+      throws Exception {
+    assume().that(isAllowDrafts()).isFalse();
+
+    setApiUser(user);
+    // We can't create a draft change while the draft workflow is disabled.
+    // For this reason, we create a normal change and modify the database.
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo)
+            .to("refs/for/master");
+    Change.Id id = changeResult.getChange().getId();
+    markChangeAsDraft(id);
+    setDraftStatusOfPatchSetsOfChange(id, true);
+
+    String changeId = changeResult.getChangeId();
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("Draft workflow is disabled");
+    gApi.changes()
+        .id(changeId)
+        .delete();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteDraftChangeWhenDraftsNotAllowedAsAdmin() throws Exception {
+    assume().that(isAllowDrafts()).isFalse();
+
+    setApiUser(user);
+    // We can't create a draft change while the draft workflow is disabled.
+    // For this reason, we create a normal change and modify the database.
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo)
+        .to("refs/for/master");
+    Change.Id id = changeResult.getChange().getId();
+    markChangeAsDraft(id);
+    setDraftStatusOfPatchSetsOfChange(id, true);
+
+    String changeId = changeResult.getChangeId();
+
+    // Grant those permissions to admins.
+    grant(Permission.VIEW_DRAFTS, project, "refs/*");
+    grant(Permission.DELETE_DRAFTS, project, "refs/*");
+
+    try {
+      setApiUser(admin);
+      gApi.changes()
+          .id(changeId)
+          .delete();
+    } finally {
+      removePermission(Permission.DELETE_DRAFTS, project, "refs/*");
+      removePermission(Permission.VIEW_DRAFTS, project, "refs/*");
+    }
+
+    setApiUser(user);
+    assertThat(query(changeId)).isEmpty();
+  }
+
+  @Test
+  public void deleteDraftChangeWithNonDraftPatchSet() throws Exception {
+    assume().that(isAllowDrafts()).isTrue();
+
+    PushOneCommit.Result changeResult = createDraftChange();
+    Change.Id id = changeResult.getChange().getId();
+    setDraftStatusOfPatchSetsOfChange(id, false);
+
+    String changeId = changeResult.getChangeId();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(String.format(
+        "Cannot delete draft change %s: patch set 1 is not a draft", id));
+    gApi.changes()
+        .id(changeId)
+        .delete();
+  }
+
+  @Test
   public void publishDraftChange() throws Exception {
     assume().that(isAllowDrafts()).isTrue();
     PushOneCommit.Result result = createDraftChange();
@@ -160,4 +261,90 @@
         + patchSet.getRevision().get()
         + "/publish");
   }
+
+  private void markChangeAsDraft(Change.Id id) throws Exception {
+    try (BatchUpdate batchUpdate = updateFactory
+        .create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+      batchUpdate.addOp(id, new MarkChangeAsDraftUpdateOp());
+      batchUpdate.execute();
+    }
+
+    ChangeStatus changeStatus = gApi.changes()
+        .id(id.get())
+        .get()
+        .status;
+    assertThat(changeStatus).isEqualTo(ChangeStatus.DRAFT);
+  }
+
+  private void setDraftStatusOfPatchSetsOfChange(Change.Id id,
+      boolean draftStatus) throws Exception {
+    try (BatchUpdate batchUpdate = updateFactory
+        .create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+      batchUpdate.addOp(id, new DraftStatusOfPatchSetsUpdateOp(draftStatus));
+      batchUpdate.execute();
+    }
+
+    Boolean expectedDraftStatus = draftStatus ? Boolean.TRUE : null;
+    List<Boolean> patchSetDraftStatuses = getPatchSetDraftStatuses(id);
+    patchSetDraftStatuses.forEach(status ->
+        assertThat(status).isEqualTo(expectedDraftStatus));
+  }
+
+  private List<Boolean> getPatchSetDraftStatuses(Change.Id id)
+      throws Exception {
+    Collection<RevisionInfo> revisionInfos = gApi.changes()
+        .id(id.get())
+        .get(EnumSet.of(ListChangesOption.ALL_REVISIONS))
+        .revisions
+        .values();
+    return revisionInfos.stream()
+        .map(revisionInfo -> revisionInfo.draft)
+        .collect(Collectors.toList());
+  }
+
+  private class MarkChangeAsDraftUpdateOp extends BatchUpdate.Op {
+    @Override
+    public boolean updateChange(BatchUpdate.ChangeContext ctx)
+        throws Exception {
+      Change change = ctx.getChange();
+
+      // Change status in database.
+      change.setStatus(Change.Status.DRAFT);
+
+      // Change status in NoteDb.
+      PatchSet.Id currentPatchSetId = change.currentPatchSetId();
+      ctx.getUpdate(currentPatchSetId).setStatus(Change.Status.DRAFT);
+
+      return true;
+    }
+  }
+
+  private class DraftStatusOfPatchSetsUpdateOp extends BatchUpdate.Op {
+    private final boolean draftStatus;
+
+    DraftStatusOfPatchSetsUpdateOp(boolean draftStatus) {
+      this.draftStatus = draftStatus;
+    }
+
+    @Override
+    public boolean updateChange(BatchUpdate.ChangeContext ctx)
+        throws Exception {
+      Collection<PatchSet> patchSets = psUtil.byChange(db, ctx.getNotes());
+
+      // Change status in database.
+      patchSets.forEach(patchSet -> patchSet.setDraft(draftStatus));
+      db.patchSets().update(patchSets);
+
+      // Change status in NoteDb.
+      PatchSetState patchSetState = draftStatus ? PatchSetState.DRAFT
+          : PatchSetState.PUBLISHED;
+      patchSets.stream()
+          .map(PatchSet::getId)
+          .map(ctx::getUpdate)
+          .forEach(changeUpdate ->
+              changeUpdate.setPatchSetState(patchSetState));
+
+      return true;
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index a044772..0c53658 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -245,10 +245,8 @@
     assertMessage(r, "Hashtag added: MyHashtag");
   }
 
-  private IterableSubject<
-        ? extends IterableSubject<?, String, Iterable<String>>,
-        String, Iterable<String>>
-      assertThatGet(PushOneCommit.Result r) throws Exception {
+  private IterableSubject assertThatGet(PushOneCommit.Result r)
+      throws Exception {
     return assertThat(gApi.changes()
         .id(r.getChange().getId().get())
         .getHashtags());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 29fda2d..e160374 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
 
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -24,14 +25,19 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
 
 import java.util.List;
+import java.util.Map;
 
 public class SubmitByMergeIfNecessaryIT extends AbstractSubmitByMerge {
 
@@ -144,6 +150,12 @@
     approve(change2a.getChangeId());
     approve(change2b.getChangeId());
     approve(change3.getChangeId());
+
+    // get a preview before submitting:
+    BinaryResult request = submitPreview(change1b.getChangeId());
+    Map<Branch.NameKey, RevTree> preview =
+        fetchFromBundles(request);
+
     submit(change1b.getChangeId());
 
     RevCommit tip1  = getRemoteLog(p1, "master").get(0);
@@ -158,11 +170,28 @@
           change2b.getCommit().getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(
           change3.getCommit().getShortMessage());
+
+      // check that the preview matched what happened:
+      assertThat(preview).hasSize(3);
+
+      assertThat(preview).containsKey(
+          new Branch.NameKey(p1, "refs/heads/master"));
+      assertRevTrees(p1, preview);
+
+      assertThat(preview).containsKey(
+          new Branch.NameKey(p2, "refs/heads/master"));
+      assertRevTrees(p2, preview);
+
+      assertThat(preview).containsKey(
+          new Branch.NameKey(p3, "refs/heads/master"));
+      assertRevTrees(p3, preview);
     } else {
       assertThat(tip2.getShortMessage()).isEqualTo(
           initialHead2.getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(
           initialHead3.getShortMessage());
+      assertThat(preview).hasSize(1);
+      assertThat(preview.get(new Branch.NameKey(p1, "refs/heads/master"))).isNotNull();
     }
   }
 
@@ -215,11 +244,23 @@
     approve(change3.getChangeId());
 
     if (isSubmitWholeTopicEnabled()) {
-      submitWithConflict(change1b.getChangeId(),
+      String msg =
           "Failed to submit 5 changes due to the following problems:\n" +
           "Change " + change3.getChange().getId() + ": Change could not be " +
           "merged due to a path conflict. Please rebase the change locally " +
-          "and upload the rebased commit for review.");
+          "and upload the rebased commit for review.";
+
+      // Get a preview before submitting:
+      try {
+        // We cannot just use the ExpectedException infrastructure as provided
+        // by AbstractDaemonTest, as then we'd stop early and not test the
+        // actual submit.
+        submitPreview(change1b.getChangeId());
+        fail("expected failure");
+      } catch (RestApiException e) {
+        assertThat(e.getMessage()).isEqualTo(msg);
+      }
+      submitWithConflict(change1b.getChangeId(), msg);
     } else {
       submit(change1b.getChangeId());
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
new file mode 100644
index 0000000..cf9651f
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class SubmitByRebaseAlwaysIT extends AbstractSubmitByRebase {
+
+  @Override
+  protected SubmitType getSubmitType() {
+    return SubmitType.REBASE_ALWAYS;
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithPossibleFastForward() throws Exception {
+    RevCommit oldHead = getRemoteHead();
+    PushOneCommit.Result change = createChange();
+    submit(change.getChangeId());
+
+    RevCommit head = getRemoteHead();
+    assertThat(head.getId()).isNotEqualTo(change.getCommit());
+    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    assertApproved(change.getChangeId());
+    assertCurrentRevision(change.getChangeId(), 2, head);
+    assertSubmitter(change.getChangeId(), 1);
+    assertSubmitter(change.getChangeId(), 2);
+    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
+    assertRefUpdatedEvents(oldHead, head);
+    assertChangeMergedEvents(change.getChangeId(), head.name());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
index 9b3fd15..431978d2 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
@@ -15,32 +15,16 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.GitUtil.getChangeId;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.Submit.TestSubmitInput;
 
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
-public class SubmitByRebaseIfNecessaryIT extends AbstractSubmit {
+public class SubmitByRebaseIfNecessaryIT extends AbstractSubmitByRebase {
 
   @Override
   protected SubmitType getSubmitType() {
@@ -67,143 +51,6 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithRebase() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "other content");
-    submit(change2.getChangeId());
-    assertRebase(testRepo, false);
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-    assertThat(headAfterSecondSubmit.getParent(0))
-        .isEqualTo(headAfterFirstSubmit);
-    assertApproved(change2.getChangeId());
-    assertCurrentRevision(change2.getChangeId(), 2, headAfterSecondSubmit);
-    assertSubmitter(change2.getChangeId(), 1);
-    assertSubmitter(change2.getChangeId(), 2);
-    assertPersonEquals(admin.getIdent(),
-        headAfterSecondSubmit.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(),
-        headAfterSecondSubmit.getCommitterIdent());
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterSecondSubmit.name());
-  }
-
-  @Test
-  public void submitWithRebaseMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change1 =
-        createChange("Change 1", "a.txt", "content");
-    submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    assertThat(headAfterFirstSubmit.name())
-        .isEqualTo(change1.getCommit().name());
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "other content");
-    assertThat(change2.getCommit().getParent(0))
-        .isNotEqualTo(change1.getCommit());
-    PushOneCommit.Result change3 =
-        createChange("Change 3", "c.txt", "third content");
-    PushOneCommit.Result change4 =
-        createChange("Change 4", "d.txt", "fourth content");
-    approve(change2.getChangeId());
-    approve(change3.getChangeId());
-    submit(change4.getChangeId());
-
-    assertRebase(testRepo, false);
-    assertApproved(change2.getChangeId());
-    assertApproved(change3.getChangeId());
-    assertApproved(change4.getChangeId());
-
-    RevCommit headAfterSecondSubmit = parse(getRemoteHead());
-    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo("Change 4");
-    assertThat(headAfterSecondSubmit).isNotEqualTo(change4.getCommit());
-    assertCurrentRevision(change4.getChangeId(), 2, headAfterSecondSubmit);
-
-    RevCommit parent = parse(headAfterSecondSubmit.getParent(0));
-    assertThat(parent.getShortMessage()).isEqualTo("Change 3");
-    assertThat(parent).isNotEqualTo(change3.getCommit());
-    assertCurrentRevision(change3.getChangeId(), 2, parent);
-
-    RevCommit grandparent = parse(parent.getParent(0));
-    assertThat(grandparent).isNotEqualTo(change2.getCommit());
-    assertCurrentRevision(change2.getChangeId(), 2, grandparent);
-
-    RevCommit greatgrandparent = parse(grandparent.getParent(0));
-    assertThat(greatgrandparent).isEqualTo(change1.getCommit());
-    assertCurrentRevision(change1.getChangeId(), 1, greatgrandparent);
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterSecondSubmit.name(),
-        change3.getChangeId(), headAfterSecondSubmit.name(),
-        change4.getChangeId(), headAfterSecondSubmit.name());
-  }
-
-  @Test
-  public void submitWithRebaseMergeCommit() throws Exception {
-    /*
-        *  (HEAD, origin/master, origin/HEAD) Merge changes X,Y
-        |\
-        | *   Merge branch 'master' into origin/master
-        | |\
-        | | * SHA Added a
-        | |/
-        * | Before
-        |/
-        * Initial empty repository
-     */
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
-
-    PushOneCommit change2Push = pushFactory.create(db, admin.getIdent(), testRepo,
-        "Merge to master", "m.txt", "");
-    change2Push.setParents(ImmutableList.of(initialHead, change1.getCommit()));
-    PushOneCommit.Result change2 = change2Push.to("refs/for/master");
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Before", "b.txt", "");
-
-    approve(change3.getChangeId());
-    submit(change3.getChangeId());
-
-    approve(change1.getChangeId());
-    approve(change2.getChangeId());
-    submit(change2.getChangeId());
-
-    RevCommit newHead = getRemoteHead();
-    assertThat(newHead.getParentCount()).isEqualTo(2);
-
-    RevCommit headParent1 = parse(newHead.getParent(0).getId());
-    RevCommit headParent2 = parse(newHead.getParent(1).getId());
-
-    assertThat(headParent1.getId()).isEqualTo(change3.getCommit().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(initialHead.getId());
-    assertThat(headGrandparent2.getId()).isEqualTo(change1.getCommit().getId());
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge() throws Exception {
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change =
@@ -235,160 +82,4 @@
         change2.getChangeId(), headAfterSecondSubmit.name(),
         change3.getChangeId(), headAfterThirdSubmit.name());
   }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "a.txt", "other content");
-    submitWithConflict(change2.getChangeId(),
-        "Cannot rebase " + change2.getCommit().name()
-        + ": The change could not be rebased due to a conflict during merge.");
-    RevCommit head = getRemoteHead();
-    assertThat(head).isEqualTo(headAfterFirstSubmit);
-    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
-    assertNoSubmitter(change2.getChangeId(), 1);
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
-  }
-
-  @Test
-  public void repairChangeStateAfterFailure() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "other content");
-    Change.Id id2 = change2.getChange().getId();
-    SubmitInput failAfterRefUpdates =
-        new TestSubmitInput(new SubmitInput(), true);
-    submit(change2.getChangeId(), failAfterRefUpdates,
-        ResourceConflictException.class, "Failing after ref updates");
-    RevCommit headAfterFailedSubmit = getRemoteHead();
-
-    // Bad: ref advanced but change wasn't updated.
-    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
-    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
-    ChangeInfo info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-    assertThat(getPatchSet(psId2)).isNull();
-
-    ObjectId rev2;
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
-      assertThat(rev1).isNotNull();
-
-      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
-      assertThat(rev2).isNotNull();
-      assertThat(rev2).isNotEqualTo(rev1);
-      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
-
-      assertThat(repo.exactRef("refs/heads/master").getObjectId())
-          .isEqualTo(rev2);
-    }
-
-    submit(change2.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
-
-    // Change status and patch set entities were updated, and branch tip stayed
-    // the same.
-    info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
-    PatchSet ps2 = getPatchSet(psId2);
-    assertThat(ps2).isNotNull();
-    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo("Change has been successfully rebased as "
-            + rev2.name() + " by Administrator");
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId())
-          .isEqualTo(rev2);
-    }
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterSecondSubmit.name());
-  }
-
-  private RevCommit parse(ObjectId id) throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit c = rw.parseCommit(id);
-      rw.parseBody(c);
-      return c;
-    }
-  }
-
-  @Test
-  public void submitAfterReorderOfCommits() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    // Create two commits and push.
-    RevCommit c1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    String id1 = getChangeId(testRepo, c1).get();
-    String id2 = getChangeId(testRepo, c2).get();
-
-    // Swap the order of commits and push again.
-    testRepo.reset("HEAD~2");
-    testRepo.cherryPick(c2);
-    testRepo.cherryPick(c1);
-    pushHead(testRepo, "refs/for/master", false);
-
-    approve(id1);
-    approve(id2);
-    submit(id1);
-    RevCommit headAfterSubmit = getRemoteHead();
-
-    assertRefUpdatedEvents(initialHead, headAfterSubmit);
-    assertChangeMergedEvents(id2, headAfterSubmit.name(),
-        id1, headAfterSubmit.name());
-  }
-
-  @Test
-  public void submitChangesAfterBranchOnSecond() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    PushOneCommit.Result change = createChange();
-    approve(change.getChangeId());
-
-    PushOneCommit.Result change2 = createChange();
-    approve(change2.getChangeId());
-    Project.NameKey project = change2.getChange().change().getProject();
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
-    createBranchWithRevision(branch, change2.getCommit().getName());
-    gApi.changes().id(change2.getChangeId()).current().submit();
-    assertMerged(change2.getChangeId());
-    assertMerged(change.getChangeId());
-
-    RevCommit newHead = getRemoteHead();
-    assertRefUpdatedEvents(initialHead, newHead);
-    assertChangeMergedEvents(change.getChangeId(), newHead.name(),
-        change2.getChangeId(), newHead.name());
-  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index d5b6f14..ce7e76d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -47,7 +48,7 @@
 @NoHttpd
 public class SubmitResolvingMergeCommitIT extends AbstractDaemonTest {
   @Inject
-  private MergeSuperSet mergeSuperSet;
+  private Provider<MergeSuperSet> mergeSuperSet;
 
   @Inject
   private Submit submit;
@@ -293,7 +294,7 @@
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
       OrmException {
     ChangeSet cs =
-        mergeSuperSet.completeChangeSet(db, change.change(), user(admin));
+        mergeSuperSet.get().completeChangeSet(db, change.change(), user(admin));
     assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index e4f56f8..184b174 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -16,21 +16,23 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.GerritConfigs;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.group.CreateGroup;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.inject.Inject;
@@ -40,7 +42,9 @@
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.stream.Collectors;
 
+@Sandboxed
 public class SuggestReviewersIT extends AbstractDaemonTest {
   @Inject
   private CreateGroup.Factory createGroupFactory;
@@ -79,10 +83,8 @@
   }
 
   @Test
-  @GerritConfigs(
-      {@GerritConfig(name = "suggest.from", value = "1"),
-       @GerritConfig(name = "accounts.visibility", value = "NONE")
-      })
+  @GerritConfig(name = "suggest.from", value = "1")
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
   public void suggestReviewersNoResult2() throws Exception {
     String changeId = createChange().getChangeId();
     List<SuggestedReviewerInfo> reviewers =
@@ -91,15 +93,6 @@
   }
 
   @Test
-  @GerritConfig(name = "suggest.from", value = "2")
-  public void suggestReviewersNoResult3() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers =
-        suggestReviewers(changeId, name("").substring(0, 1), 6);
-    assertThat(reviewers).isEmpty();
-  }
-
-  @Test
   public void suggestReviewersChange() throws Exception {
     String changeId = createChange().getChangeId();
     List<SuggestedReviewerInfo> reviewers =
@@ -206,7 +199,7 @@
     assertThat(reviewers).hasSize(1);
 
     reviewers = suggestReviewers(changeId, "example.com", 7);
-    assertThat(reviewers).hasSize(6);
+    assertThat(reviewers).hasSize(5);
 
     reviewers = suggestReviewers(changeId, user1.email, 2);
     assertThat(reviewers).hasSize(1);
@@ -231,10 +224,8 @@
   }
 
   @Test
-  @GerritConfigs({
-    @GerritConfig(name = "addreviewer.maxAllowed", value="2"),
-    @GerritConfig(name = "addreviewer.maxWithoutConfirmation", value="1"),
-  })
+  @GerritConfig(name = "addreviewer.maxAllowed", value="2")
+  @GerritConfig(name = "addreviewer.maxWithoutConfirmation", value="1")
   public void suggestReviewersGroupSizeConsiderations() throws Exception {
     AccountGroup largeGroup = group("large");
     AccountGroup mediumGroup = group("medium");
@@ -269,6 +260,158 @@
     assertThat(reviewer.confirm).isTrue();
   }
 
+  @Test
+  public void defaultReviewerSuggestion() throws Exception{
+    TestAccount user1 = user("customuser1", "User1");
+    TestAccount reviewer1 = user("customuser2", "User2");
+    TestAccount reviewer2 = user("customuser3", "User3");
+
+    setApiUser(user1);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(user1);
+    String changeId2 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId2);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    setApiUser(user1);
+    String changeId3 = createChangeFromApi();
+    List<SuggestedReviewerInfo>  reviewers =
+        suggestReviewers(changeId3, null, 4);
+    assertThat(
+        reviewers.stream()
+            .map(r -> r.account._accountId)
+            .collect(Collectors.toList()))
+        .containsExactly(
+            reviewer1.id.get(),
+            reviewer2.id.get())
+        .inOrder();
+
+    // check that existing reviewers are filtered out
+    gApi.changes().id(changeId3).addReviewer(reviewer1.email);
+    reviewers =
+        suggestReviewers(changeId3, null, 4);
+    assertThat(
+        reviewers.stream()
+            .map(r -> r.account._accountId)
+            .collect(Collectors.toList()))
+        .containsExactly(
+            reviewer2.id.get())
+        .inOrder();
+  }
+
+  @Test
+  public void defaultReviewerSuggestionOnFirstChange() throws Exception{
+    TestAccount user1 = user("customuser1", "User1");
+    setApiUser(user1);
+    List<SuggestedReviewerInfo>  reviewers =
+        suggestReviewers(createChange().getChangeId(), "", 4);
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "suggest.maxSuggestedReviewers", value = "10")
+  public void reviewerRanking() throws Exception{
+    // Assert that user are ranked by the number of times they have applied a
+    // a label to a change (highest), added comments (medium) or owned a
+    // change (low).
+    String fullName = "Primum Finalis";
+    TestAccount userWhoOwns = user("customuser1", fullName);
+    TestAccount reviewer1 = user("customuser2", fullName);
+    TestAccount reviewer2 = user("customuser3", fullName);
+    TestAccount userWhoComments = user("customuser4", fullName);
+    TestAccount userWhoLooksForSuggestions = user("customuser5", fullName);
+
+    // Create a change as userWhoOwns and add some reviews
+    setApiUser(userWhoOwns);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(user1);
+    String changeId2 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId2);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    // Create a comment as a different user
+    setApiUser(userWhoComments);
+    ReviewInput ri = new ReviewInput();
+    ri.message = "Test";
+    gApi.changes().id(changeId1).revision(1).review(ri);
+
+    // Create a change as a new user to assert that we receive the correct
+    // ranking
+
+    setApiUser(userWhoLooksForSuggestions);
+    List<SuggestedReviewerInfo>  reviewers =
+        suggestReviewers(createChangeFromApi(), "Pri", 4);
+    assertThat(
+        reviewers.stream()
+            .map(r -> r.account._accountId)
+            .collect(Collectors.toList()))
+        .containsExactly(
+            reviewer1.id.get(),
+            reviewer2.id.get(),
+            userWhoOwns.id.get(),
+            userWhoComments.id.get())
+        .inOrder();
+  }
+
+  @Test
+  public void reviewerRankingProjectIsolation() throws Exception{
+    // Create new project
+    Project.NameKey newProject = createProject("test");
+
+    // Create users who review changes in both the default and the new project
+    String fullName = "Primum Finalis";
+    TestAccount userWhoOwns = user("customuser1", fullName);
+    TestAccount reviewer1 = user("customuser2", fullName);
+    TestAccount reviewer2 = user("customuser3", fullName);
+
+    setApiUser(userWhoOwns);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(userWhoOwns);
+    String changeId2 = createChangeFromApi(newProject);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    setApiUser(userWhoOwns);
+    String changeId3 = createChangeFromApi(newProject);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId3);
+
+    setApiUser(userWhoOwns);
+    List<SuggestedReviewerInfo> reviewers =
+        suggestReviewers(createChangeFromApi(), "Prim", 4);
+
+    // Assert that reviewer1 is on top, even though reviewer2 has more reviews
+    // in other projects
+    assertThat(
+        reviewers.stream()
+            .map(r -> r.account._accountId)
+            .collect(Collectors.toList()))
+        .containsExactly(reviewer1.id.get(), reviewer2.id.get())
+        .inOrder();
+  }
+
   private List<SuggestedReviewerInfo> suggestReviewers(String changeId,
       String query, int n) throws Exception {
     return gApi.changes()
@@ -287,13 +430,9 @@
 
   private TestAccount user(String name, String fullName, String emailName,
       AccountGroup... groups) throws Exception {
-    String[] groupNames = FluentIterable.from(Arrays.asList(groups))
-        .transform(new Function<AccountGroup, String>() {
-          @Override
-          public String apply(AccountGroup in) {
-            return in.getName();
-          }
-        }).toArray(String.class);
+    String[] groupNames = Arrays.stream(groups)
+        .map(AccountGroup::getName)
+        .toArray(String[]::new);
     return accounts.create(name(name), name(emailName) + "@example.com",
         fullName, groupNames);
   }
@@ -302,4 +441,23 @@
       throws Exception {
     return user(name, fullName, name, groups);
   }
+
+  private void reviewChange(String changeId) throws RestApiException {
+    ReviewInput ri = new ReviewInput();
+    ri.label("Code-Review", 1);
+    gApi.changes().id(changeId).current().review(ri);
+  }
+
+  private String createChangeFromApi() throws RestApiException{
+    return createChangeFromApi(project);
+  }
+
+  private String createChangeFromApi(Project.NameKey project)
+      throws RestApiException{
+    ChangeInput ci = new ChangeInput();
+    ci.project = project.get();
+    ci.subject = "Test change at" + System.nanoTime();
+    ci.branch = "master";
+    return gApi.changes().create(ci).get().changeId;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 54fa74c..cac293c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -19,67 +19,66 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.GerritConfigs;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AuthType;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.GetServerInfo.ServerInfo;
 
 import org.junit.Test;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
 
+@NoHttpd
 public class ServerInfoIT extends AbstractDaemonTest {
 
   @Test
-  @GerritConfigs({
-    // auth
-    @GerritConfig(name = "auth.type", value = "HTTP"),
-    @GerritConfig(name = "auth.contributorAgreements", value = "true"),
-    @GerritConfig(name = "auth.loginUrl", value = "https://example.com/login"),
-    @GerritConfig(name = "auth.loginText", value = "LOGIN"),
-    @GerritConfig(name = "auth.switchAccountUrl", value = "https://example.com/switch"),
+  // auth
+  @GerritConfig(name = "auth.type", value = "HTTP")
+  @GerritConfig(name = "auth.contributorAgreements", value = "true")
+  @GerritConfig(name = "auth.loginUrl", value = "https://example.com/login")
+  @GerritConfig(name = "auth.loginText", value = "LOGIN")
+  @GerritConfig(name = "auth.switchAccountUrl", value = "https://example.com/switch")
 
-    // auth fields ignored when auth == HTTP
-    @GerritConfig(name = "auth.registerUrl", value = "https://example.com/register"),
-    @GerritConfig(name = "auth.registerText", value = "REGISTER"),
-    @GerritConfig(name = "auth.editFullNameUrl", value = "https://example.com/editname"),
-    @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password"),
+  // auth fields ignored when auth == HTTP
+  @GerritConfig(name = "auth.registerUrl", value = "https://example.com/register")
+  @GerritConfig(name = "auth.registerText", value = "REGISTER")
+  @GerritConfig(name = "auth.editFullNameUrl", value = "https://example.com/editname")
+  @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password")
 
-    // change
-    @GerritConfig(name = "change.allowDrafts", value = "false"),
-    @GerritConfig(name = "change.largeChange", value = "300"),
-    @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments"),
-    @GerritConfig(name = "change.replyLabel", value = "Vote"),
-    @GerritConfig(name = "change.updateDelay", value = "50s"),
+  // change
+  @GerritConfig(name = "change.allowDrafts", value = "false")
+  @GerritConfig(name = "change.largeChange", value = "300")
+  @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
+  @GerritConfig(name = "change.replyLabel", value = "Vote")
+  @GerritConfig(name = "change.updateDelay", value = "50s")
 
-    // download
-    @GerritConfig(name = "download.archive", values = {"tar",
-        "tbz2", "tgz", "txz"}),
+  // download
+  @GerritConfig(name = "download.archive", values = {"tar",
+      "tbz2", "tgz", "txz"})
 
-    // gerrit
-    @GerritConfig(name = "gerrit.allProjects", value = "Root"),
-    @GerritConfig(name = "gerrit.allUsers", value = "Users"),
-    @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report"),
-    @GerritConfig(name = "gerrit.reportBugText", value = "REPORT BUG"),
+  // gerrit
+  @GerritConfig(name = "gerrit.allProjects", value = "Root")
+  @GerritConfig(name = "gerrit.allUsers", value = "Users")
+  @GerritConfig(name = "gerrit.enableGwtUi", value = "true")
+  @GerritConfig(name = "gerrit.reportBugText", value = "REPORT BUG")
+  @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report")
 
-    // suggest
-    @GerritConfig(name = "suggest.from", value = "3"),
+  // suggest
+  @GerritConfig(name = "suggest.from", value = "3")
 
-    // user
-    @GerritConfig(name = "user.anonymousCoward", value = "Unnamed User"),
-  })
+  // user
+  @GerritConfig(name = "user.anonymousCoward", value = "Unnamed User")
   public void serverConfig() throws Exception {
-    ServerInfo i = getServerConfig();
+    ServerInfo i = gApi.config().server().getInfo();
 
     // auth
     assertThat(i.auth.authType).isEqualTo(AuthType.HTTP);
     assertThat(i.auth.editableAccountFields).containsExactly(
-        Account.FieldName.REGISTER_NEW_EMAIL, Account.FieldName.FULL_NAME);
+        AccountFieldName.REGISTER_NEW_EMAIL, AccountFieldName.FULL_NAME);
     assertThat(i.auth.useContributorAgreements).isTrue();
     assertThat(i.auth.loginUrl).isEqualTo("https://example.com/login");
     assertThat(i.auth.loginText).isEqualTo("LOGIN");
@@ -107,6 +106,9 @@
     assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
     assertThat(i.gerrit.reportBugText).isEqualTo("REPORT BUG");
 
+    // Acceptance tests force --headless even when UIs are specified in config.
+    assertThat(i.gerrit.webUis).isEmpty();
+
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
 
@@ -121,9 +123,9 @@
 
     // notedb
     notesMigration.setReadChanges(true);
-    assertThat(getServerConfig().noteDbEnabled).isTrue();
+    assertThat(gApi.config().server().getInfo().noteDbEnabled).isTrue();
     notesMigration.setReadChanges(false);
-    assertThat(getServerConfig().noteDbEnabled).isNull();
+    assertThat(gApi.config().server().getInfo().noteDbEnabled).isNull();
   }
 
   @Test
@@ -134,7 +136,7 @@
     Files.write(jsplugin, "Gerrit.install(function(self){});\n".getBytes(UTF_8));
     adminSshSession.exec("gerrit plugin reload");
 
-    ServerInfo i = getServerConfig();
+    ServerInfo i = gApi.config().server().getInfo();
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).hasSize(1);
@@ -142,13 +144,13 @@
 
   @Test
   public void serverConfigWithDefaults() throws Exception {
-    ServerInfo i = getServerConfig();
+    ServerInfo i = gApi.config().server().getInfo();
 
     // auth
     assertThat(i.auth.authType).isEqualTo(AuthType.OPENID);
     assertThat(i.auth.editableAccountFields).containsExactly(
-        Account.FieldName.REGISTER_NEW_EMAIL, Account.FieldName.FULL_NAME,
-        Account.FieldName.USER_NAME);
+        AccountFieldName.REGISTER_NEW_EMAIL, AccountFieldName.FULL_NAME,
+        AccountFieldName.USER_NAME);
     assertThat(i.auth.useContributorAgreements).isNull();
     assertThat(i.auth.loginUrl).isNull();
     assertThat(i.auth.loginText).isNull();
@@ -189,9 +191,12 @@
     assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
   }
 
-  private ServerInfo getServerConfig() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/info/");
-    r.assertOK();
-    return newGson().fromJson(r.getReader(), ServerInfo.class);
+  @Test
+  @GerritConfig(name = "auth.contributorAgreements", value = "true")
+  public void anonymousAccess() throws Exception {
+    configureContributorAgreement(true);
+
+    setApiUserAnonymous();
+    gApi.config().server().getInfo();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java
index c860bf0..522836d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 
@@ -48,12 +47,7 @@
   }
 
   private static Iterable<String> refs(Iterable<BranchInfo> infos) {
-    return Iterables.transform(infos, new Function<BranchInfo, String>() {
-      @Override
-      public String apply(BranchInfo in) {
-        return in.ref;
-      }
-    });
+    return Iterables.transform(infos, b -> b.ref);
   }
 
   private static boolean toBoolean(Boolean b) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 46f93b6..6377710 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -67,9 +67,9 @@
   }
 
   @Test
-  public void createBranchByAdminCreateReferenceBlocked() throws Exception {
+  public void createBranchByAdminCreateReferenceBlocked_Forbidden() throws Exception {
     blockCreateReference();
-    assertCreateSucceeds();
+    assertCreateFails(AuthException.class);
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 955e580..1c9711f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -36,6 +36,7 @@
 
   @Before
   public void setUp() throws Exception {
+    project = createProject(name("p"));
     branch = new Branch.NameKey(project, "test");
     branch().create(new BranchInput());
   }
@@ -73,10 +74,32 @@
     assertDeleteForbidden();
   }
 
+  @Test
+  public void deleteBranchByUserWithForcePushPermission() throws Exception {
+    grantForcePush();
+    setApiUser(user);
+    assertDeleteSucceeds();
+  }
+
+  @Test
+  public void deleteBranchByUserWithDeletePermission() throws Exception {
+    grantDelete();
+    setApiUser(user);
+    assertDeleteSucceeds();
+  }
+
   private void blockForcePush() throws Exception {
     block(Permission.PUSH, ANONYMOUS_USERS, "refs/heads/*").setForce(true);
   }
 
+  private void grantForcePush() throws Exception {
+    grant(Permission.PUSH, project, "refs/heads/*", true, ANONYMOUS_USERS);
+  }
+
+  private void grantDelete() throws Exception {
+    allow(Permission.DELETE, ANONYMOUS_USERS, "refs/*");
+  }
+
   private void grantOwner() throws Exception {
     allow(Permission.OWNER, REGISTERED_USERS, "refs/*");
   }
@@ -99,6 +122,7 @@
 
   private void assertDeleteForbidden() throws Exception {
     exception.expect(AuthException.class);
+    exception.expectMessage("Cannot delete branch");
     branch().delete();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index e86bb29..496e7fd 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Predicate;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -208,15 +207,14 @@
   }
 
   private Iterable<ProjectInfo> filter(Iterable<ProjectInfo> infos) {
-    final String prefix = name("");
-    return Iterables.filter(infos, new Predicate<ProjectInfo>() {
-      @Override
-      public boolean apply(ProjectInfo in) {
-        return in.name != null && (
-            in.name.equals(allProjects.get())
-            || in.name.equals(allUsers.get())
-            || in.name.startsWith(prefix));
-      }
-    });
+    String prefix = name("");
+    return Iterables.filter(
+        infos,
+        p -> {
+          return p.name != null && (
+              p.name.equals(allProjects.get())
+              || p.name.equals(allUsers.get())
+              || p.name.startsWith(prefix));
+        });
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
index db6df95..e3104bb 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
-import com.google.common.base.Function;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -32,12 +31,8 @@
 import java.util.Set;
 
 public class ProjectAssert {
-  public static IterableSubject<
-        ? extends IterableSubject<
-            ?, Project.NameKey, Iterable<Project.NameKey>>,
-        Project.NameKey,
-        Iterable<Project.NameKey>>
-      assertThatNameList(Iterable<ProjectInfo> actualIt) {
+  public static IterableSubject assertThatNameList(
+      Iterable<ProjectInfo> actualIt) {
     List<ProjectInfo> actual = ImmutableList.copyOf(actualIt);
     for (ProjectInfo info : actual) {
       assertWithMessage("missing project name").that(info.name).isNotNull();
@@ -45,13 +40,8 @@
           .that(Url.decode(info.id))
           .isEqualTo(info.name);
     }
-    return assertThat(Iterables.transform(actual,
-        new Function<ProjectInfo, Project.NameKey>() {
-          @Override
-          public Project.NameKey apply(ProjectInfo in) {
-            return new Project.NameKey(in.name);
-          }
-        }));
+    return assertThat(
+        Iterables.transform(actual, p -> new Project.NameKey(p.name)));
   }
 
   public static void assertProjectInfo(Project project, ProjectInfo info) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java
new file mode 100644
index 0000000..01a2443
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java
@@ -0,0 +1,290 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.createAnnotatedTag;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.GitUtil.updateAnnotatedTag;
+import static com.google.gerrit.acceptance.rest.project.PushTagIT.TagType.ANNOTATED;
+import static com.google.gerrit.acceptance.rest.project.PushTagIT.TagType.LIGHTWEIGHT;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.reviewdb.client.RefNames;
+
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class PushTagIT extends AbstractDaemonTest {
+  enum TagType {
+    LIGHTWEIGHT(Permission.CREATE),
+    ANNOTATED(Permission.CREATE_TAG);
+
+    final String createPermission;
+
+    TagType(String createPermission) {
+      this.createPermission = createPermission;
+    }
+  }
+
+  private RevCommit initialHead;
+
+  @Before
+  public void setup() throws Exception {
+    // clone with user to avoid inherited tag permissions of admin user
+    testRepo = cloneProject(project, user);
+
+    initialHead = getRemoteHead();
+  }
+
+  @Test
+  public void createTagForExistingCommit() throws Exception {
+    for (TagType tagType : TagType.values()) {
+      pushTagForExistingCommit(tagType, Status.REJECTED_OTHER_REASON);
+
+      allowTagCreation(tagType);
+      pushTagForExistingCommit(tagType, Status.OK);
+
+      allowPushOnRefsTags();
+      pushTagForExistingCommit(tagType, Status.OK);
+
+      removePushFromRefsTags();
+    }
+  }
+
+  @Test
+  public void createTagForNewCommit() throws Exception {
+    for (TagType tagType : TagType.values()) {
+      pushTagForNewCommit(tagType, Status.REJECTED_OTHER_REASON);
+
+      allowTagCreation(tagType);
+      pushTagForNewCommit(tagType, Status.REJECTED_OTHER_REASON);
+
+      allowPushOnRefsTags();
+      pushTagForNewCommit(tagType, Status.OK);
+
+      removePushFromRefsTags();
+    }
+  }
+
+  @Test
+  public void fastForward() throws Exception {
+    for (TagType tagType : TagType.values()) {
+      allowTagCreation(tagType);
+      String tagName = pushTagForExistingCommit(tagType, Status.OK);
+
+      fastForwardTagToExistingCommit(tagType, tagName,
+          Status.REJECTED_OTHER_REASON);
+      fastForwardTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowTagDeletion();
+      fastForwardTagToExistingCommit(tagType, tagName,
+          Status.REJECTED_OTHER_REASON);
+      fastForwardTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowPushOnRefsTags();
+      Status expectedStatus =
+          tagType == ANNOTATED ? Status.REJECTED_OTHER_REASON : Status.OK;
+      fastForwardTagToExistingCommit(tagType, tagName, expectedStatus);
+      fastForwardTagToNewCommit(tagType, tagName, expectedStatus);
+
+      allowForcePushOnRefsTags();
+      fastForwardTagToExistingCommit(tagType, tagName, Status.OK);
+      fastForwardTagToNewCommit(tagType, tagName, Status.OK);
+
+      removePushFromRefsTags();
+    }
+  }
+
+  @Test
+  public void forceUpdate() throws Exception {
+    for (TagType tagType : TagType.values()) {
+      allowTagCreation(tagType);
+      String tagName = pushTagForExistingCommit(tagType, Status.OK);
+
+      forceUpdateTagToExistingCommit(tagType, tagName,
+          Status.REJECTED_OTHER_REASON);
+      forceUpdateTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowPushOnRefsTags();
+      forceUpdateTagToExistingCommit(tagType, tagName,
+          Status.REJECTED_OTHER_REASON);
+      forceUpdateTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowTagDeletion();
+      forceUpdateTagToExistingCommit(tagType, tagName,
+          Status.REJECTED_OTHER_REASON);
+      forceUpdateTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowForcePushOnRefsTags();
+      forceUpdateTagToExistingCommit(tagType, tagName, Status.OK);
+      forceUpdateTagToNewCommit(tagType, tagName, Status.OK);
+
+      removePushFromRefsTags();
+    }
+  }
+
+  @Test
+  public void delete() throws Exception {
+    for (TagType tagType : TagType.values()) {
+      allowTagCreation(tagType);
+      String tagName = pushTagForExistingCommit(tagType, Status.OK);
+
+      pushTagDeletion(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowPushOnRefsTags();
+      pushTagDeletion(tagType, tagName, Status.REJECTED_OTHER_REASON);
+    }
+
+    allowForcePushOnRefsTags();
+    for (TagType tagType : TagType.values()) {
+      String tagName = pushTagForExistingCommit(tagType, Status.OK);
+      pushTagDeletion(tagType, tagName, Status.OK);
+    }
+
+    removePushFromRefsTags();
+    allowTagDeletion();
+    for (TagType tagType : TagType.values()) {
+      String tagName = pushTagForExistingCommit(tagType, Status.OK);
+      pushTagDeletion(tagType, tagName, Status.OK);
+    }
+  }
+
+  private String pushTagForExistingCommit(TagType tagType,
+      Status expectedStatus) throws Exception {
+    return pushTag(tagType, null, false, false, expectedStatus);
+  }
+
+  private String pushTagForNewCommit(TagType tagType,
+      Status expectedStatus) throws Exception {
+    return pushTag(tagType, null, true, false, expectedStatus);
+  }
+
+  private void fastForwardTagToExistingCommit(TagType tagType, String tagName,
+      Status expectedStatus) throws Exception {
+    pushTag(tagType, tagName, false, false, expectedStatus);
+  }
+
+  private void fastForwardTagToNewCommit(TagType tagType, String tagName,
+      Status expectedStatus) throws Exception {
+    pushTag(tagType, tagName, true, false, expectedStatus);
+  }
+
+  private void forceUpdateTagToExistingCommit(TagType tagType, String tagName,
+      Status expectedStatus) throws Exception {
+    pushTag(tagType, tagName, false, true, expectedStatus);
+  }
+
+  private void forceUpdateTagToNewCommit(TagType tagType, String tagName,
+      Status expectedStatus) throws Exception {
+    pushTag(tagType, tagName, true, true, expectedStatus);
+  }
+
+  private String pushTag(TagType tagType, String tagName, boolean newCommit,
+      boolean force, Status expectedStatus) throws Exception {
+    if (force) {
+      testRepo.reset(initialHead);
+    }
+    commit(user.getIdent(), "subject");
+
+    boolean createTag = tagName == null;
+    tagName = MoreObjects.firstNonNull(tagName, "v1" + "_" + System.nanoTime());
+    switch (tagType) {
+      case LIGHTWEIGHT:
+        break;
+      case ANNOTATED:
+        if (createTag) {
+          createAnnotatedTag(testRepo, tagName, user.getIdent());
+        } else {
+          updateAnnotatedTag(testRepo, tagName, user.getIdent());
+        }
+        break;
+      default:
+        throw new IllegalStateException("unexpected tag type: " + tagType);
+    }
+
+    if (!newCommit) {
+      grant(Permission.SUBMIT, project, "refs/for/refs/heads/master", false,
+          REGISTERED_USERS);
+      pushHead(testRepo, "refs/for/master%submit");
+    }
+
+    String tagRef = tagRef(tagName);
+    PushResult r = tagType == LIGHTWEIGHT
+        ? pushHead(testRepo, tagRef, false, force)
+        : GitUtil.pushTag(testRepo, tagName, !createTag);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
+    assertThat(refUpdate.getStatus())
+        .named(tagType.name())
+        .isEqualTo(expectedStatus);
+    return tagName;
+  }
+
+  private void pushTagDeletion(TagType tagType, String tagName,
+      Status expectedStatus) throws Exception {
+    String tagRef = tagRef(tagName);
+    PushResult r = deleteRef(testRepo, tagRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
+    assertThat(refUpdate.getStatus()).named(tagType.name())
+        .isEqualTo(expectedStatus);
+  }
+
+  private void allowTagCreation(TagType tagType) throws Exception {
+    grant(tagType.createPermission, project, "refs/tags/*", false,
+        REGISTERED_USERS);
+  }
+
+  private void allowPushOnRefsTags() throws Exception {
+    removePushFromRefsTags();
+    grant(Permission.PUSH, project, "refs/tags/*", false, REGISTERED_USERS);
+  }
+
+  private void allowForcePushOnRefsTags() throws Exception {
+    removePushFromRefsTags();
+    grant(Permission.PUSH, project, "refs/tags/*", true, REGISTERED_USERS);
+  }
+
+  private void allowTagDeletion() throws Exception {
+    removePushFromRefsTags();
+    grant(Permission.DELETE, project, "refs/tags/*", true, REGISTERED_USERS);
+  }
+
+  private void removePushFromRefsTags() throws Exception {
+    removePermission(Permission.PUSH, project, "refs/tags/*");
+  }
+
+  private void commit(PersonIdent ident, String subject) throws Exception {
+    commitBuilder()
+        .ident(ident)
+        .message(subject + " (" + System.nanoTime() + ")")
+        .create();
+  }
+
+  private static String tagRef(String tagName) {
+    return RefNames.REFS_TAGS + tagName;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 33aa726..c4aee29 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -238,6 +238,7 @@
 
   @Test
   public void createTagNotAllowed() throws Exception {
+    block(Permission.CREATE, REGISTERED_USERS, R_TAGS + "*");
     TagInput input = new TagInput();
     input.ref = "test";
     exception.expect(AuthException.class);
@@ -247,7 +248,7 @@
 
   @Test
   public void createAnnotatedTagNotAllowed() throws Exception {
-    block(Permission.PUSH_TAG, REGISTERED_USERS, R_TAGS + "*");
+    block(Permission.CREATE_TAG, REGISTERED_USERS, R_TAGS + "*");
     TagInput input = new TagInput();
     input.ref = "test";
     input.message = "annotation";
@@ -338,8 +339,8 @@
 
   private void grantTagPermissions() throws Exception {
     grant(Permission.CREATE, project, R_TAGS + "*");
-    grant(Permission.PUSH_TAG, project, R_TAGS + "*");
-    grant(Permission.PUSH_SIGNED_TAG, project, R_TAGS + "*");
+    grant(Permission.CREATE_TAG, project, R_TAGS + "*");
+    grant(Permission.CREATE_SIGNED_TAG, project, R_TAGS + "*");
   }
 
   private ListRefsRequest<TagInfo> getTags() throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
index d9f1a5c..f0f4566 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -32,9 +32,11 @@
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.PostReview;
@@ -52,6 +54,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -148,8 +151,8 @@
   @Test
   public void postCommentOnMergeCommitChange() throws Exception {
     for (Integer line : lines) {
-      final String file = "/COMMIT_MSG";
-      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+      String file = "foo";
+      PushOneCommit.Result r = createMergeCommitChange("refs/for/master", file);
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
@@ -165,6 +168,39 @@
       assertThat(Lists.transform(result.get(file), infoToInput(file)))
           .containsExactly(c1, c2, c3, c4);
     }
+
+    // for the commit message comments on the auto-merge are not possible
+    for (Integer line : lines) {
+      String file = Patch.COMMIT_MSG;
+      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      ReviewInput input = new ReviewInput();
+      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1");
+      CommentInput c2 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c3 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      input.comments = new HashMap<>();
+      input.comments.put(file, ImmutableList.of(c1, c2, c3));
+      revision(r).review(input);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      assertThat(result).isNotEmpty();
+      assertThat(Lists.transform(result.get(file), infoToInput(file)))
+          .containsExactly(c1, c2, c3);
+    }
+  }
+
+  @Test
+  public void postCommentOnCommitMessageOnAutoMerge() throws Exception {
+    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+    ReviewInput input = new ReviewInput();
+    CommentInput c =
+        newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge");
+    input.comments = new HashMap<>();
+    input.comments.put(Patch.COMMIT_MSG, ImmutableList.of(c));
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "cannot comment on " + Patch.COMMIT_MSG + " on auto-merge");
+    revision(r).review(input);
   }
 
   @Test
@@ -361,7 +397,7 @@
     setApiUser(admin);
     Map<String, List<CommentInfo>> actual =
         gApi.changes().id(r1.getChangeId()).drafts();
-    assertThat((Iterable<?>) actual.keySet()).containsExactly(FILE_NAME);
+    assertThat(actual.keySet()).containsExactly(FILE_NAME);
     List<CommentInfo> comments = actual.get(FILE_NAME);
     assertThat(comments).hasSize(2);
 
@@ -504,8 +540,7 @@
     assertThat(ps2List.get(2).message).isEqualTo("join lines");
     assertThat(ps2List.get(3).message).isEqualTo("typo: content");
 
-    ImmutableList<Message> messages =
-        email.getMessages(r2.getChangeId(), "comment");
+    List<Message> messages = email.getMessages(r2.getChangeId(), "comment");
     assertThat(messages).hasSize(1);
     String url = canonicalWebUrl.get();
     int c = r1.getChange().getId().get();
@@ -519,10 +554,12 @@
         + url + "#/c/" + c + "/1/a.txt\n"
         + "File a.txt:\n"
         + "\n"
+        + url + "#/c/12/1/a.txt@a2\n"
         + "PS1, Line 2: \n"
         + "what happened to this?\n"
         + "\n"
         + "\n"
+        + url + "#/c/12/1/a.txt@1\n"
         + "PS1, Line 1: ew\n"
         + "nit: trailing whitespace\n"
         + "\n"
@@ -530,20 +567,25 @@
         + url + "#/c/" + c + "/2/a.txt\n"
         + "File a.txt:\n"
         + "\n"
+        + url + "#/c/12/2/a.txt@a1\n"
         + "PS2, Line 1: \n"
         + "comment 1 on base\n"
         + "\n"
         + "\n"
+        + url + "#/c/12/2/a.txt@a2\n"
         + "PS2, Line 2: \n"
         + "comment 2 on base\n"
         + "\n"
         + "\n"
+        + url + "#/c/12/2/a.txt@1\n"
         + "PS2, Line 1: ew\n"
         + "join lines\n"
         + "\n"
         + "\n"
+        + url + "#/c/12/2/a.txt@2\n"
         + "PS2, Line 2: nten\n"
         + "typo: content\n"
+        + "\n"
         + "\n");
   }
 
@@ -687,29 +729,21 @@
     return c;
   }
 
-  private static Function<CommentInfo, CommentInput> infoToInput(
-      final String path) {
-    return new Function<CommentInfo, CommentInput>() {
-      @Override
-      public CommentInput apply(CommentInfo info) {
-        CommentInput ci = new CommentInput();
-        ci.path = path;
-        copy(info, ci);
-        return ci;
-      }
-    };
+  private static Function<CommentInfo, CommentInput> infoToInput(String path) {
+    return infoToInput(path, CommentInput::new);
   }
 
-  private static Function<CommentInfo, DraftInput> infoToDraft(
-      final String path) {
-    return new Function<CommentInfo, DraftInput>() {
-      @Override
-      public DraftInput apply(CommentInfo info) {
-        DraftInput di = new DraftInput();
-        di.path = path;
-        copy(info, di);
-        return di;
-      }
+  private static Function<CommentInfo, DraftInput> infoToDraft(String path) {
+    return infoToInput(path, DraftInput::new);
+  }
+
+  private static <I extends Comment> Function<CommentInfo, I> infoToInput(
+      String path, Supplier<I> supplier) {
+    return info -> {
+      I i = supplier.get();
+      i.path = path;
+      copy(info, i);
+      return i;
     };
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 37e551f..e33d163 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -796,7 +796,7 @@
       ins = patchSetInserterFactory.create(ctl, nextPatchSetId(ctl), commit)
           .setValidatePolicy(CommitValidators.Policy.NONE)
           .setFireRevisionCreated(false)
-          .setSendMail(false);
+          .setNotify(NotifyHandling.NONE);
       bu.addOp(ctl.getId(), ins).execute();
     }
     return reload(ctl);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 40ea296..37ced5f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -646,6 +647,39 @@
         changeAndCommit(psId1_1, c1_1, 1));
   }
 
+  @Test
+  public void getRelatedForStaleChange() throws Exception {
+    RevCommit c1_1 = commitBuilder()
+        .add("a.txt", "1")
+        .message("subject: 1")
+        .create();
+
+    RevCommit c2_1 = commitBuilder()
+        .add("b.txt", "1")
+        .message("subject: 1")
+        .create();
+    pushHead(testRepo, "refs/for/master", false);
+
+    RevCommit c2_2 = testRepo.amend(c2_1)
+        .add("b.txt", "2")
+        .create();
+    testRepo.reset(c2_2);
+
+    disableChangeIndexWrites();
+    try {
+      pushHead(testRepo, "refs/for/master", false);
+    } finally {
+      enableChangeIndexWrites();
+    }
+
+    PatchSet.Id psId1_1 = getPatchSetId(c1_1);
+    PatchSet.Id psId2_1 = getPatchSetId(c2_1);
+    PatchSet.Id psId2_2 = new PatchSet.Id(psId2_1.changeId, psId2_1.get() + 1);
+
+    assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2),
+        changeAndCommit(psId1_1, c1_1, 1));
+  }
+
   private List<ChangeAndCommit> getRelated(PatchSet.Id ps) throws Exception {
     return getRelated(ps.getParentKey(), ps.get());
   }
@@ -654,8 +688,9 @@
       throws Exception {
     String url = String.format("/changes/%d/revisions/%d/related",
         changeId.get(), ps);
-    return newGson().fromJson(adminRestSession.get(url).getReader(),
-        RelatedInfo.class).changes;
+    RestResponse r = adminRestSession.get(url);
+    r.assertOK();
+    return newGson().fromJson(r.getReader(), RelatedInfo.class).changes;
   }
 
   private RevCommit parseBody(RevCommit c) throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 06170d0..b843721 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -23,8 +23,11 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testutil.ConfigSuite;
@@ -44,6 +47,63 @@
   }
 
   @Test
+  public void doesNotIncludeCurrentFiles() throws Exception {
+    RevCommit c1_1 = commitBuilder()
+        .add("a.txt", "1")
+        .message("subject: 1")
+        .create();
+    RevCommit c2_1 = commitBuilder()
+        .add("b.txt", "2")
+        .message("subject: 2")
+        .create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    SubmittedTogetherInfo info =
+        gApi.changes()
+            .id(id2)
+            .submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+    assertThat(info.changes).hasSize(2);
+    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
+    assertThat(info.changes.get(1).currentRevision).isEqualTo(c1_1.name());
+
+    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
+    RevisionInfo rev = info.changes.get(0).revisions.get(c2_1.name());
+    assertThat(rev.files).isNull();
+  }
+
+  @Test
+  public void returnsCurrentFilesIfOptionRequested() throws Exception {
+    RevCommit c1_1 = commitBuilder()
+        .add("a.txt", "1")
+        .message("subject: 1")
+        .create();
+    RevCommit c2_1 = commitBuilder()
+        .add("b.txt", "2")
+        .message("subject: 2")
+        .create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    SubmittedTogetherInfo info =
+        gApi.changes()
+            .id(id2)
+            .submittedTogether(
+                EnumSet.of(ListChangesOption.CURRENT_FILES),
+                EnumSet.of(NON_VISIBLE_CHANGES));
+    assertThat(info.changes).hasSize(2);
+    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
+    assertThat(info.changes.get(1).currentRevision).isEqualTo(c1_1.name());
+
+    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
+    RevisionInfo rev = info.changes.get(0).revisions.get(c2_1.name());
+    assertThat(rev).isNotNull();
+    FileInfo file = rev.files.get("b.txt");
+    assertThat(file).isNotNull();
+    assertThat(file.status).isEqualTo('A');
+  }
+
+  @Test
   public void returnsAncestors() throws Exception {
     // Create two commits and push.
     RevCommit c1_1 = commitBuilder()
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index b443e66..e1771ce 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -18,22 +18,27 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Ordering;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -42,12 +47,13 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.PostReview;
 import com.google.gerrit.server.change.Rebuild;
@@ -55,14 +61,20 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.RepoRefCache;
 import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeRebuilder.NoPatchSetsException;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
+import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.NoteDbChecker;
 import com.google.gerrit.testutil.NoteDbMode;
@@ -72,6 +84,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
+import org.apache.http.Header;
+import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -85,7 +99,9 @@
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 
 public class ChangeRebuilderIT extends AbstractDaemonTest {
@@ -109,7 +125,7 @@
   private Provider<ReviewDb> dbProvider;
 
   @Inject
-  private PatchLineCommentsUtil plcUtil;
+  private CommentsUtil commentsUtil;
 
   @Inject
   private Provider<PostReview> postReview;
@@ -123,6 +139,9 @@
   @Inject
   private Sequences seq;
 
+  @Inject
+  private ChangeBundleReader bundleReader;
+
   @Before
   public void setUp() throws Exception {
     assume().that(NoteDbMode.readWrite()).isFalse();
@@ -387,8 +406,8 @@
 
     // Check that the bundles are equal.
     ChangeBundle actual = ChangeBundle.fromNotes(
-        plcUtil, notesFactory.create(dbProvider.get(), project, id));
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+        commentsUtil, notesFactory.create(dbProvider.get(), project, id));
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
   }
 
@@ -438,18 +457,13 @@
 
     // Check that the bundles are equal.
     ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
     assertThat(
             Iterables.transform(
                 notes.getChangeMessages(),
-                new Function<ChangeMessage, String>() {
-                  @Override
-                  public String apply(ChangeMessage in) {
-                    return in.getMessage();
-                  }
-                }))
+                ChangeMessage::getMessage))
         .contains(msg);
   }
 
@@ -477,8 +491,8 @@
 
     // Check that the bundles are equal.
     ChangeBundle actual = ChangeBundle.fromNotes(
-        plcUtil, notesFactory.create(dbProvider.get(), project, id));
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+        commentsUtil, notesFactory.create(dbProvider.get(), project, id));
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
   }
 
@@ -507,8 +521,8 @@
     // Not up to date, but the actual returned state matches anyway.
     assertChangeUpToDate(false, id);
     assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
-    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
     assertChangeUpToDate(false, id);
 
@@ -549,8 +563,8 @@
 
     // Not up to date, but the actual returned state matches anyway.
     assertDraftsUpToDate(false, id, user);
-    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
 
     // Another rebuild attempt succeeds
@@ -581,11 +595,15 @@
     ReviewDb db = getUnwrappedDb();
     Change c = db.changes().get(id);
     // Leave change meta ID alone so DraftCommentNotes does the rebuild.
+    ObjectId badSha =
+        ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
     NoteDbChangeState bogusState = new NoteDbChangeState(
-        id, NoteDbChangeState.parse(c).getChangeMetaId(),
-        ImmutableMap.<Account.Id, ObjectId>of(
-            user.getId(),
-            ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")));
+        id,
+        PrimaryStorage.REVIEW_DB,
+        Optional.of(
+          NoteDbChangeState.RefState.create(
+              NoteDbChangeState.parse(c).getChangeMetaId(),
+              ImmutableMap.of(user.getId(), badSha))));
     c.setNoteDbState(bogusState.toString());
     db.changes().update(Collections.singleton(c));
 
@@ -604,8 +622,8 @@
     // Not up to date, but the actual returned state matches anyway.
     assertChangeUpToDate(true, id);
     assertDraftsUpToDate(false, id, user);
-    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
 
     // Another rebuild attempt succeeds
@@ -711,6 +729,8 @@
     rin.message = "comment";
 
     Timestamp ts = new Timestamp(c.getCreatedOn().getTime() + 2000);
+    assertThat(ts).isGreaterThan(c.getCreatedOn());
+    assertThat(ts).isLessThan(db.patchSets().get(psId).getCreatedOn());
     RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
     postReview.get().apply(revRsrc, rin, ts);
 
@@ -971,6 +991,136 @@
     checker.rebuildAndCheckChanges(id);
   }
 
+  @Test
+  public void rebuildEntitiesCreatedByImpersonation() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    PatchSet.Id psId = new PatchSet.Id(id, 1);
+    String prefix = "/changes/" + id + "/revisions/current/";
+
+    // For each of the entities that have a real user field, create one entity
+    // without impersonation and one with.
+    CommentInput ci = new CommentInput();
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "comment without impersonation";
+    ReviewInput ri = new ReviewInput();
+    ri.label("Code-Review", -1);
+    ri.message = "message without impersonation";
+    ri.drafts = DraftHandling.KEEP;
+    ri.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    userRestSession.post(prefix + "review", ri).assertOK();
+
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "draft without impersonation";
+    userRestSession.put(prefix + "drafts", di).assertCreated();
+
+    allowRunAs();
+    try {
+      Header runAs = new BasicHeader("X-Gerrit-RunAs", user.id.toString());
+      ci.message = "comment with impersonation";
+      ri.message = "message with impersonation";
+      ri.label("Code-Review", 1);
+      adminRestSession.postWithHeader(prefix + "review", ri, runAs).assertOK();
+
+      di.message = "draft with impersonation";
+      adminRestSession.putWithHeader(prefix + "drafts", runAs, di)
+          .assertCreated();
+    } finally {
+      removeRunAs();
+    }
+
+    List<ChangeMessage> msgs =
+        Ordering.natural().onResultOf(ChangeMessage::getWrittenOn)
+            .sortedCopy(db.changeMessages().byChange(id));
+    assertThat(msgs).hasSize(3);
+    assertThat(msgs.get(1).getMessage())
+        .endsWith("message without impersonation");
+    assertThat(msgs.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(1).getRealAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(2).getMessage()).endsWith("message with impersonation");
+    assertThat(msgs.get(2).getAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(2).getRealAuthor()).isEqualTo(admin.id);
+
+    List<PatchSetApproval> psas = db.patchSetApprovals().byChange(id).toList();
+    assertThat(psas).hasSize(1);
+    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).getValue()).isEqualTo(1);
+    assertThat(psas.get(0).getAccountId()).isEqualTo(user.id);
+    assertThat(psas.get(0).getRealAccountId()).isEqualTo(admin.id);
+
+    Ordering<PatchLineComment> commentOrder =
+        Ordering.natural().onResultOf(PatchLineComment::getWrittenOn);
+    List<PatchLineComment> drafts = commentOrder.sortedCopy(
+        db.patchComments().draftByPatchSetAuthor(psId, user.id));
+    assertThat(drafts).hasSize(2);
+    assertThat(drafts.get(0).getMessage())
+        .isEqualTo("draft without impersonation");
+    assertThat(drafts.get(0).getAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(0).getRealAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(1).getMessage())
+        .isEqualTo("draft with impersonation");
+    assertThat(drafts.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(1).getRealAuthor()).isEqualTo(admin.id);
+
+    List<PatchLineComment> pub = commentOrder.sortedCopy(
+        db.patchComments().publishedByPatchSet(psId));
+    assertThat(pub).hasSize(2);
+    assertThat(pub.get(0).getMessage())
+        .isEqualTo("comment without impersonation");
+    assertThat(pub.get(0).getAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(0).getRealAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(1).getMessage()).isEqualTo("comment with impersonation");
+    assertThat(pub.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(1).getRealAuthor()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void laterEventsDependingOnEarlierPatchSetDontIntefereWithOtherPatchSets()
+      throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    ChangeData cd = r1.getChange();
+    Change.Id id = cd.getId();
+    amendChange(cd.change().getKey().get());
+    TestTimeUtil.incrementClock(90, TimeUnit.DAYS);
+
+    ReviewInput rin = ReviewInput.approve();
+    rin.message = "Some very late message on PS1";
+    gApi.changes().id(id.get()).revision(1).review(rin);
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void ignoreChangeMessageBeyondCurrentPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId1 = r.getPatchSetId();
+    Change.Id id = psId1.getParentKey();
+    gApi.changes().id(id.get()).current().review(ReviewInput.recommend());
+
+    r = amendChange(r.getChangeId());
+    PatchSet.Id psId2 = r.getPatchSetId();
+
+    assertThat(db.patchSets().byChange(id)).hasSize(2);
+    assertThat(db.changeMessages().byPatchSet(psId2)).hasSize(1);
+    db.patchSets().deleteKeys(Collections.singleton(psId2));
+
+    checker.rebuildAndCheckChanges(psId2.getParentKey());
+    setNotesMigration(true, true);
+
+    ChangeData cd = changeDataFactory.create(db, project, id);
+    assertThat(cd.change().currentPatchSetId()).isEqualTo(psId1);
+    assertThat(cd.patchSets().stream().map(ps -> ps.getId()).collect(toList()))
+        .containsExactly(psId1);
+    PatchSet ps = cd.currentPatchSet();
+    assertThat(ps).isNotNull();
+    assertThat(ps.getId()).isEqualTo(psId1);
+  }
+
   private void assertChangesReadOnly(RestApiException e) throws Exception {
     Throwable cause = e.getCause();
     assertThat(cause).isInstanceOf(UpdateException.class);
@@ -1086,4 +1236,19 @@
     ReviewDb db = dbProvider.get();
     return ReviewDbUtil.unwrapDb(db);
   }
+
+  private void allowRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.allow(cfg, GlobalCapability.RUN_AS,
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private void removeRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.remove(cfg, GlobalCapability.RUN_AS,
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD
index bcf9c9f..458b5eb 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD
@@ -1,16 +1,7 @@
 load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
 
-FLAKY_TEST_CASES=['ProjectWatchIT.java']
-
 acceptance_tests(
   group = 'server_project',
-  srcs = glob(['*IT.java'], exclude=FLAKY_TEST_CASES),
+  srcs = glob(['*IT.java']),
   labels = ['server'],
 )
-
-acceptance_tests(
-  group = 'server_project_flaky',
-  flaky = 1,
-  srcs = FLAKY_TEST_CASES,
-  labels = ['server', 'flaky'],
-)
diff --git a/gerrit-acceptance-tests/tests.bzl b/gerrit-acceptance-tests/tests.bzl
index ff2562d..160234f 100644
--- a/gerrit-acceptance-tests/tests.bzl
+++ b/gerrit-acceptance-tests/tests.bzl
@@ -11,8 +11,8 @@
     flaky = 0,
     deps = [],
     labels = [],
-    source_under_test = [], #unused
-    vm_args = ['-Xmx256m']):
+    vm_args = ['-Xmx256m'],
+    **kwargs):
   junit_tests(
     name = group,
     srcs = srcs,
@@ -25,4 +25,5 @@
       'slow',
     ],
     jvm_flags = vm_args,
+    **kwargs
   )
diff --git a/gerrit-acceptance-tests/tests.defs b/gerrit-acceptance-tests/tests.defs
index 85cc78b..648bd63 100644
--- a/gerrit-acceptance-tests/tests.defs
+++ b/gerrit-acceptance-tests/tests.defs
@@ -8,7 +8,6 @@
     srcs,
     deps = [],
     labels = [],
-    source_under_test = [],
     vm_args = ['-Xmx256m']):
   from os import path
   if path.exists('/dev/urandom'):
@@ -20,11 +19,6 @@
     deps = deps + BOUNCYCASTLE + [
       '//gerrit-acceptance-tests:lib'
     ],
-    source_under_test = [
-      '//gerrit-httpd:httpd',
-      '//gerrit-sshd:sshd',
-      '//gerrit-server:server',
-    ] + source_under_test,
     labels = labels + [
       'acceptance',
       'slow',
diff --git a/gerrit-antlr/BUILD b/gerrit-antlr/BUILD
index c955ab1..6eedccf 100644
--- a/gerrit-antlr/BUILD
+++ b/gerrit-antlr/BUILD
@@ -18,7 +18,7 @@
     '@bazel_tools//tools/zip:zipper',
     '//lib/antlr:antlr-tool',
   ],
-  out = 'query_antlr.srcjar',
+  outs = [ 'query_antlr.srcjar' ],
 )
 
 java_library(
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 5009771..f7381a3 100644
--- a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -119,19 +119,8 @@
   public void start() {
     if (executor != null) {
       for (final H2CacheImpl<?, ?> cache : caches) {
-        executor.execute(new Runnable() {
-          @Override
-          public void run() {
-            cache.start();
-          }
-        });
-
-        cleanup.schedule(new Runnable() {
-          @Override
-          public void run() {
-            cache.prune(cleanup);
-          }
-        }, 30, TimeUnit.SECONDS);
+        executor.execute(cache::start);
+        cleanup.schedule(() -> cache.prune(cleanup), 30, TimeUnit.SECONDS);
       }
     }
   }
@@ -189,7 +178,7 @@
   public <K, V> LoadingCache<K, V> build(
       CacheBinding<K, V> def,
       CacheLoader<K, V> loader) {
-    long limit = config.getLong("cache", def.name(), "diskLimit", 128 << 20);
+    long limit = config.getLong("cache", def.name(), "diskLimit", def.diskLimit());
 
     if (cacheDir == null || limit <= 0) {
       return defaultFactory.build(def, loader);
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 838f42c..7e05236 100644
--- a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -144,24 +144,14 @@
     final ValueHolder<V> h = new ValueHolder<>(val);
     h.created = TimeUtil.nowMs();
     mem.put(key, h);
-    executor.execute(new Runnable() {
-      @Override
-      public void run() {
-        store.put(key, h);
-      }
-    });
+    executor.execute(() -> store.put(key, h));
   }
 
   @SuppressWarnings("unchecked")
   @Override
   public void invalidate(final Object key) {
     if (keyType.getRawType().isInstance(key) && store.mightContain((K) key)) {
-      executor.execute(new Runnable() {
-        @Override
-        public void run() {
-          store.invalidate((K) key);
-        }
-      });
+      executor.execute(() -> store.invalidate((K) key));
     }
     mem.invalidate(key);
   }
@@ -212,12 +202,7 @@
     cal.add(Calendar.DAY_OF_MONTH, 1);
 
     long delay = cal.getTimeInMillis() - TimeUtil.nowMs();
-    service.schedule(new Runnable() {
-      @Override
-      public void run() {
-        prune(service);
-      }
-    }, delay, TimeUnit.MILLISECONDS);
+    service.schedule(() -> prune(service), delay, TimeUnit.MILLISECONDS);
   }
 
   static class ValueHolder<V> {
@@ -252,12 +237,7 @@
 
       final ValueHolder<V> h = new ValueHolder<>(loader.load(key));
       h.created = TimeUtil.nowMs();
-      executor.execute(new Runnable() {
-        @Override
-        public void run() {
-          store.put(key, h);
-        }
-      });
+      executor.execute(() -> store.put(key, h));
       return h;
     }
   }
@@ -280,14 +260,9 @@
         }
       }
 
-      final ValueHolder<V> h = new ValueHolder<V>(loader.call());
+      final ValueHolder<V> h = new ValueHolder<>(loader.call());
       h.created = TimeUtil.nowMs();
-      executor.execute(new Runnable() {
-        @Override
-        public void run() {
-          store.put(key, h);
-        }
-      });
+      executor.execute(() -> store.put(key, h));
       return h;
     }
   }
diff --git a/gerrit-common/BUCK b/gerrit-common/BUCK
index 847fd25..b2ae8b0 100644
--- a/gerrit-common/BUCK
+++ b/gerrit-common/BUCK
@@ -61,8 +61,8 @@
     ':client',
     '//lib:guava',
     '//lib:junit',
+    '//lib:truth',
   ],
-  source_under_test = [':client'],
 )
 
 java_test(
diff --git a/gerrit-common/BUILD b/gerrit-common/BUILD
index 86ba087..9add6e7 100644
--- a/gerrit-common/BUILD
+++ b/gerrit-common/BUILD
@@ -64,6 +64,7 @@
     ':client',
     '//lib:guava',
     '//lib:junit',
+    '//lib:truth',
   ],
 )
 
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
index 43d4441..795ec6a 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
@@ -96,6 +96,10 @@
     return toChangeQuery(op("owner", fullname) + " " + status(status));
   }
 
+  public static String toAssigneeQuery(String fullname) {
+    return toChangeQuery(op("assignee", fullname));
+  }
+
   public static String toCustomDashboard(final String params) {
     return "/dashboard/?" + params;
   }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountSecurity.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountSecurity.java
index 752f0d2..afd6734 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountSecurity.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountSecurity.java
@@ -16,13 +16,11 @@
 
 import com.google.gerrit.common.audit.Audit;
 import com.google.gerrit.common.auth.SignInRequired;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwtjsonrpc.common.RemoteJsonService;
 import com.google.gwtjsonrpc.common.RpcImpl;
 import com.google.gwtjsonrpc.common.RpcImpl.Version;
-import com.google.gwtjsonrpc.common.VoidResult;
 
 import java.util.List;
 import java.util.Set;
@@ -36,14 +34,4 @@
   @SignInRequired
   void deleteExternalIds(Set<AccountExternalId.Key> keys,
       AsyncCallback<Set<AccountExternalId.Key>> callback);
-
-  @Audit
-  @SignInRequired
-  void updateContact(String fullName, String emailAddr,
-      AsyncCallback<Account> callback);
-
-  @Audit
-  @SignInRequired
-  void enterAgreement(String agreementName,
-      AsyncCallback<VoidResult> callback);
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountService.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountService.java
deleted file mode 100644
index 22482c7..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountService.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.common.auth.SignInRequired;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.common.RpcImpl;
-import com.google.gwtjsonrpc.common.RpcImpl.Version;
-
-@RpcImpl(version = Version.V2_0)
-public interface AccountService extends RemoteJsonService {
-  @SignInRequired
-  void myAgreements(AsyncCallback<AgreementInfo> callback);
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/CommentDetail.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/CommentDetail.java
index 1b98b09..ff5402f 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/CommentDetail.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/CommentDetail.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 
 import java.util.ArrayList;
@@ -24,14 +25,14 @@
 import java.util.Map;
 
 public class CommentDetail {
-  protected List<PatchLineComment> a;
-  protected List<PatchLineComment> b;
+  protected List<Comment> a;
+  protected List<Comment> b;
   protected AccountInfoCache accounts;
 
   private transient PatchSet.Id idA;
   private transient PatchSet.Id idB;
-  private transient Map<Integer, List<PatchLineComment>> forA;
-  private transient Map<Integer, List<PatchLineComment>> forB;
+  private transient Map<Integer, List<Comment>> forA;
+  private transient Map<Integer, List<Comment>> forB;
 
   public CommentDetail(PatchSet.Id idA, PatchSet.Id idB) {
     this.a = new ArrayList<>();
@@ -43,9 +44,9 @@
   protected CommentDetail() {
   }
 
-  public boolean include(final PatchLineComment p) {
-    final PatchSet.Id psId = p.getKey().getParentKey().getParentKey();
-    switch (p.getSide()) {
+  public boolean include(Change.Id changeId, Comment p) {
+    PatchSet.Id psId = new PatchSet.Id(changeId, p.key.patchSetId);
+    switch (p.side) {
       case 0:
         if (idA == null && idB.equals(psId)) {
           a.add(p);
@@ -76,11 +77,11 @@
     return accounts;
   }
 
-  public List<PatchLineComment> getCommentsA() {
+  public List<Comment> getCommentsA() {
     return a;
   }
 
-  public List<PatchLineComment> getCommentsB() {
+  public List<Comment> getCommentsB() {
     return b;
   }
 
@@ -88,24 +89,23 @@
     return a.isEmpty() && b.isEmpty();
   }
 
-  public List<PatchLineComment> getForA(final int lineNbr) {
+  public List<Comment> getForA(int lineNbr) {
     if (forA == null) {
       forA = index(a);
     }
     return get(forA, lineNbr);
   }
 
-  public List<PatchLineComment> getForB(final int lineNbr) {
+  public List<Comment> getForB(int lineNbr) {
     if (forB == null) {
       forB = index(b);
     }
     return get(forB, lineNbr);
   }
 
-  private static List<PatchLineComment> get(
-      final Map<Integer, List<PatchLineComment>> m, final int i) {
-    final List<PatchLineComment> r = m.get(i);
-    return r != null ? orderComments(r) : Collections.<PatchLineComment> emptyList();
+  private static List<Comment> get(Map<Integer, List<Comment>> m, int i) {
+    List<Comment> r = m.get(i);
+    return r != null ? orderComments(r) : Collections.<Comment> emptyList();
   }
 
   /**
@@ -116,21 +116,21 @@
    * @param comments The list of comments for a given line.
    * @return The comments sorted as they should appear in the UI
    */
-  private static List<PatchLineComment> orderComments(List<PatchLineComment> comments) {
+  private static List<Comment> orderComments(List<Comment> comments) {
     // Map of comments keyed by their parent. The values are lists of comments since it is
     // possible for several comments to have the same parent (this can happen if two reviewers
     // click Reply on the same comment at the same time). Such comments will be displayed under
     // their correct parent in chronological order.
-    Map<String, List<PatchLineComment>> parentMap = new HashMap<>();
+    Map<String, List<Comment>> parentMap = new HashMap<>();
 
     // It's possible to have more than one root comment if two reviewers create a comment on the
     // same line at the same time
-    List<PatchLineComment> rootComments = new ArrayList<>();
+    List<Comment> rootComments = new ArrayList<>();
 
     // Store all the comments in parentMap, keyed by their parent
-    for (PatchLineComment c : comments) {
-      String parentUuid = c.getParentUuid();
-      List<PatchLineComment> l = parentMap.get(parentUuid);
+    for (Comment c : comments) {
+      String parentUuid = c.parentUuid;
+      List<Comment> l = parentMap.get(parentUuid);
       if (l == null) {
         l = new ArrayList<>();
         parentMap.put(parentUuid, l);
@@ -143,7 +143,7 @@
 
     // Add the comments in the list, starting with the head and then going through all the
     // comments that have it as a parent, and so on
-    List<PatchLineComment> result = new ArrayList<>();
+    List<Comment> result = new ArrayList<>();
     addChildren(parentMap, rootComments, result);
 
     return result;
@@ -152,24 +152,23 @@
   /**
    * Add the comments to {@code outResult}, depth first
    */
-  private static void addChildren(Map<String, List<PatchLineComment>> parentMap,
-      List<PatchLineComment> children, List<PatchLineComment> outResult) {
+  private static void addChildren(Map<String, List<Comment>> parentMap,
+      List<Comment> children, List<Comment> outResult) {
     if (children != null) {
-      for (PatchLineComment c : children) {
+      for (Comment c : children) {
         outResult.add(c);
-        addChildren(parentMap, parentMap.get(c.getKey().get()), outResult);
+        addChildren(parentMap, parentMap.get(c.key.uuid), outResult);
       }
     }
   }
 
-  private Map<Integer, List<PatchLineComment>> index(
-      List<PatchLineComment> in) {
-    HashMap<Integer, List<PatchLineComment>> r = new HashMap<>();
-    for (final PatchLineComment p : in) {
-      List<PatchLineComment> l = r.get(p.getLine());
+  private Map<Integer, List<Comment>> index(List<Comment> in) {
+    HashMap<Integer, List<Comment>> r = new HashMap<>();
+    for (Comment p : in) {
+      List<Comment> l = r.get(p.lineNbr);
       if (l == null) {
         l = new ArrayList<>();
-        r.put(p.getLine(), l);
+        r.put(p.lineNbr, l);
       }
       l.add(p);
     }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java
new file mode 100644
index 0000000..535130a1
--- /dev/null
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import com.google.gerrit.reviewdb.client.Patch;
+
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.Set;
+
+public class FilenameComparator implements Comparator<String> {
+  public static final FilenameComparator INSTANCE = new FilenameComparator();
+
+  private static final Set<String> cppHeaderSuffixes = new HashSet<>(
+      Arrays.asList(".h", ".hxx", ".hpp"));
+
+  private FilenameComparator() {}
+
+  @Override
+  public int compare(final String path1, final String path2) {
+    if (Patch.COMMIT_MSG.equals(path1) && Patch.COMMIT_MSG.equals(path2)) {
+      return 0;
+    } else if (Patch.COMMIT_MSG.equals(path1)) {
+      return -1;
+    } else if (Patch.COMMIT_MSG.equals(path2)) {
+      return 1;
+    }
+    if (Patch.MERGE_LIST.equals(path1) && Patch.MERGE_LIST.equals(path2)) {
+      return 0;
+    } else if (Patch.MERGE_LIST.equals(path1)) {
+      return -1;
+    } else if (Patch.MERGE_LIST.equals(path2)) {
+      return 1;
+    }
+
+    int s1 = path1.lastIndexOf('.');
+    int s2 = path2.lastIndexOf('.');
+    if (s1 > 0 && s2 > 0 &&
+        path1.substring(0, s1).equals(path2.substring(0, s2))) {
+      String suffixA = path1.substring(s1);
+      String suffixB = path2.substring(s2);
+      // C++ and C: give priority to header files (.h/.hpp/...)
+      if (cppHeaderSuffixes.contains(suffixA)) {
+        return -1;
+      } else if (cppHeaderSuffixes.contains(suffixB)) {
+        return 1;
+      }
+    }
+    return path1.compareTo(path2);
+  }
+}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
index 97f11b4..290b9f9 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
@@ -24,8 +24,12 @@
   public static final String ABANDON = "abandon";
   public static final String ADD_PATCH_SET = "addPatchSet";
   public static final String CREATE = "create";
+  public static final String DELETE = "delete";
+  public static final String CREATE_TAG = "createTag";
+  public static final String CREATE_SIGNED_TAG = "createSignedTag";
   public static final String DELETE_DRAFTS = "deleteDrafts";
   public static final String EDIT_HASHTAGS = "editHashtags";
+  public static final String EDIT_ASSIGNEE = "editAssignee";
   public static final String EDIT_TOPIC_NAME = "editTopicName";
   public static final String FORGE_AUTHOR = "forgeAuthor";
   public static final String FORGE_COMMITTER = "forgeCommitter";
@@ -36,8 +40,6 @@
   public static final String PUBLISH_DRAFTS = "publishDrafts";
   public static final String PUSH = "push";
   public static final String PUSH_MERGE = "pushMerge";
-  public static final String PUSH_TAG = "pushTag";
-  public static final String PUSH_SIGNED_TAG = "pushSignedTag";
   public static final String READ = "read";
   public static final String REBASE = "rebase";
   public static final String REMOVE_REVIEWER = "removeReviewer";
@@ -46,8 +48,8 @@
   public static final String VIEW_DRAFTS = "viewDrafts";
 
   private static final List<String> NAMES_LC;
-  private static final int labelIndex;
-  private static final int labelAsIndex;
+  private static final int LABEL_INDEX;
+  private static final int LABEL_AS_INDEX;
 
   static {
     NAMES_LC = new ArrayList<>();
@@ -56,13 +58,14 @@
     NAMES_LC.add(ABANDON.toLowerCase());
     NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
     NAMES_LC.add(CREATE.toLowerCase());
+    NAMES_LC.add(CREATE_TAG.toLowerCase());
+    NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
+    NAMES_LC.add(DELETE.toLowerCase());
     NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
     NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
     NAMES_LC.add(FORGE_SERVER.toLowerCase());
     NAMES_LC.add(PUSH.toLowerCase());
     NAMES_LC.add(PUSH_MERGE.toLowerCase());
-    NAMES_LC.add(PUSH_TAG.toLowerCase());
-    NAMES_LC.add(PUSH_SIGNED_TAG.toLowerCase());
     NAMES_LC.add(LABEL.toLowerCase());
     NAMES_LC.add(LABEL_AS.toLowerCase());
     NAMES_LC.add(REBASE.toLowerCase());
@@ -72,11 +75,12 @@
     NAMES_LC.add(VIEW_DRAFTS.toLowerCase());
     NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
     NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
+    NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
     NAMES_LC.add(DELETE_DRAFTS.toLowerCase());
     NAMES_LC.add(PUBLISH_DRAFTS.toLowerCase());
 
-    labelIndex = NAMES_LC.indexOf(Permission.LABEL);
-    labelAsIndex = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
+    LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
+    LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
   }
 
   /** @return true if the name is recognized as a permission name. */
@@ -247,9 +251,9 @@
 
   private static int index(Permission a) {
     if (isLabel(a.getName())) {
-      return labelIndex;
+      return LABEL_INDEX;
     } else if (isLabelAs(a.getName())) {
-      return labelAsIndex;
+      return LABEL_AS_INDEX;
     }
 
     int index = NAMES_LC.indexOf(a.getName().toLowerCase());
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java
index 9dccf0c..3dc41fe 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java
@@ -16,14 +16,27 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 
+import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
+import java.util.Optional;
 
 /**
  * Describes the state required to submit a change.
  */
 public class SubmitRecord {
+  public static Optional<SubmitRecord> findOkRecord(
+      Collection<SubmitRecord> in) {
+    if (in == null) {
+      return Optional.empty();
+    }
+    return in.stream().filter(r -> r.status == Status.OK).findFirst();
+  }
+
   public enum Status {
+    // NOTE: These values are persisted in the index, so deleting or changing
+    // the name of any values requires a schema upgrade.
+
     /** The change is ready for submission. */
     OK,
 
@@ -50,6 +63,9 @@
 
   public static class Label {
     public enum Status {
+      // NOTE: These values are persisted in the index, so deleting or changing
+      // the name of any values requires a schema upgrade.
+
       /**
        * This label provides what is necessary for submission.
        * <p>
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SystemInfoService.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SystemInfoService.java
index 272801f..fb54ef1 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SystemInfoService.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SystemInfoService.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.common.auth.SignInRequired;
 import com.google.gwtjsonrpc.common.AllowCrossSiteRequest;
 import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwtjsonrpc.common.RemoteJsonService;
@@ -29,8 +28,5 @@
   @AllowCrossSiteRequest
   void daemonHostKeys(AsyncCallback<List<SshHostKey>> callback);
 
-  @SignInRequired
-  void contributorAgreements(AsyncCallback<List<ContributorAgreement>> callback);
-
   void clientError(String message, AsyncCallback<VoidResult> callback);
 }
diff --git a/gerrit-common/src/test/java/com/google/gerrit/common/data/FilenameComparatorTest.java b/gerrit-common/src/test/java/com/google/gerrit/common/data/FilenameComparatorTest.java
new file mode 100644
index 0000000..ef8f0a9
--- /dev/null
+++ b/gerrit-common/src/test/java/com/google/gerrit/common/data/FilenameComparatorTest.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class FilenameComparatorTest {
+  private FilenameComparator comparator = FilenameComparator.INSTANCE;
+
+  @Test
+  public void basicPaths() {
+    assertThat(comparator.compare(
+        "abc/xyz/FileOne.java", "xyz/abc/FileTwo.java")).isLessThan(0);
+    assertThat(comparator.compare(
+        "abc/xyz/FileOne.java", "abc/xyz/FileOne.java")).isEqualTo(0);
+    assertThat(comparator.compare(
+        "zzz/yyy/FileOne.java", "abc/xyz/FileOne.java")).isGreaterThan(0);
+  }
+
+  @Test
+  public void specialPaths() {
+    assertThat(comparator.compare(
+        "ABC/xyz/FileOne.java", "/COMMIT_MSG")).isGreaterThan(0);
+    assertThat(comparator.compare(
+        "/COMMIT_MSG", "ABC/xyz/FileOne.java")).isLessThan(0);
+
+    assertThat(comparator.compare(
+        "ABC/xyz/FileOne.java", "/MERGE_LIST")).isGreaterThan(0);
+    assertThat(comparator.compare(
+        "/MERGE_LIST", "ABC/xyz/FileOne.java")).isLessThan(0);
+
+    assertThat(comparator.compare(
+        "/COMMIT_MSG", "/MERGE_LIST")).isLessThan(0);
+    assertThat(comparator.compare(
+        "/MERGE_LIST", "/COMMIT_MSG")).isGreaterThan(0);
+
+    assertThat(comparator.compare(
+        "/COMMIT_MSG", "/COMMIT_MSG")).isEqualTo(0);
+    assertThat(comparator.compare(
+        "/MERGE_LIST", "/MERGE_LIST")).isEqualTo(0);
+  }
+
+  @Test
+  public void cppExtensions() {
+    assertThat(comparator.compare("abc/file.h", "abc/file.cc")).isLessThan(0);
+    assertThat(comparator.compare("abc/file.c", "abc/file.hpp"))
+        .isGreaterThan(0);
+    assertThat(comparator.compare("abc..xyz.file.h", "abc.xyz.file.cc"))
+        .isLessThan(0);
+  }
+}
\ No newline at end of file
diff --git a/gerrit-elasticsearch/BUCK b/gerrit-elasticsearch/BUCK
new file mode 100644
index 0000000..a16ad50
--- /dev/null
+++ b/gerrit-elasticsearch/BUCK
@@ -0,0 +1,51 @@
+java_library(
+  name = 'elasticsearch',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = [
+    '//gerrit-antlr:query_exception',
+    '//gerrit-extension-api:api',
+    '//gerrit-lucene:lucene', # only for LuceneAccountIndex
+    '//gerrit-reviewdb:client',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//gerrit-index:index',
+    '//lib:gson',
+    '//lib:guava',
+    '//lib:gwtorm',
+    '//lib:protobuf',
+    '//lib/commons:codec',
+    '//lib/commons:lang',
+    '//lib/elasticsearch:elasticsearch',
+    '//lib/elasticsearch:jest',
+    '//lib/elasticsearch:jest-common',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/joda:joda-time',
+    '//lib/log:api',
+    '//lib/lucene:lucene-analyzers-common',
+    '//lib/lucene:lucene-core',
+  ],
+  visibility = ['PUBLIC'],
+)
+
+java_test(
+  name = 'elasticsearch_tests',
+  labels = ['elastic', 'flaky'],
+  srcs = glob(['src/test/java/**/*.java']),
+  deps = [
+    ':elasticsearch',
+    '//gerrit-extension-api:api',
+    '//gerrit-server:server',
+    '//gerrit-server:testutil',
+    '//gerrit-server:query_tests',
+    '//lib:gson',
+    '//lib:guava',
+    '//lib:junit',
+    '//lib:truth',
+    '//lib/elasticsearch:elasticsearch',
+    '//lib/guice:guice',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
+  ],
+)
diff --git a/gerrit-elasticsearch/BUILD b/gerrit-elasticsearch/BUILD
new file mode 100644
index 0000000..bb1f0e2
--- /dev/null
+++ b/gerrit-elasticsearch/BUILD
@@ -0,0 +1,53 @@
+java_library(
+  name = 'elasticsearch',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = [
+    '//gerrit-antlr:query_exception',
+    '//gerrit-extension-api:api',
+    '//gerrit-lucene:lucene', # only for LuceneAccountIndex
+    '//gerrit-reviewdb:client',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//gerrit-index:index',
+    '//lib:gson',
+    '//lib:guava',
+    '//lib:gwtorm',
+    '//lib:protobuf',
+    '//lib/commons:codec',
+    '//lib/commons:lang',
+    '//lib/elasticsearch:elasticsearch',
+    '//lib/elasticsearch:jest',
+    '//lib/elasticsearch:jest-common',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/joda:joda-time',
+    '//lib/log:api',
+    '//lib/lucene:lucene-analyzers-common',
+    '//lib/lucene:lucene-core',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+load('//tools/bzl:junit.bzl', 'junit_tests')
+
+junit_tests(
+  name = 'elasticsearch_tests',
+  tags = ['elastic', 'flaky'],
+  srcs = glob(['src/test/java/**/*.java']),
+  deps = [
+    ':elasticsearch',
+    '//gerrit-extension-api:api',
+    '//gerrit-server:server',
+    '//gerrit-server:testutil',
+    '//gerrit-server:query_tests_code',
+    '//lib:gson',
+    '//lib:guava',
+    '//lib:junit',
+    '//lib:truth',
+    '//lib/elasticsearch:elasticsearch',
+    '//lib/guice:guice',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
+  ],
+)
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
new file mode 100644
index 0000000..a46edc7
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -0,0 +1,207 @@
+// Copyright (C) 2014 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.elasticsearch;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
+import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.index.IndexUtils;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.FieldDef.FillArgs;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.Schema.Values;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.concurrent.TimeUnit;
+
+import io.searchbox.client.JestClientFactory;
+import io.searchbox.client.JestResult;
+import io.searchbox.client.config.HttpClientConfig;
+import io.searchbox.client.http.JestHttpClient;
+import io.searchbox.core.Bulk;
+import io.searchbox.core.Delete;
+import io.searchbox.indices.CreateIndex;
+import io.searchbox.indices.DeleteIndex;
+import io.searchbox.indices.IndicesExists;
+
+abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
+  private static final String DEFAULT_INDEX_NAME = "gerrit";
+
+  private final Schema<V> schema;
+  private final FillArgs fillArgs;
+  private final SitePaths sitePaths;
+
+  protected final boolean refresh;
+  protected final String indexName;
+  protected final JestHttpClient client;
+
+
+  @Inject
+  AbstractElasticIndex(@GerritServerConfig Config cfg,
+      FillArgs fillArgs,
+      SitePaths sitePaths,
+      @Assisted Schema<V> schema) {
+    this.fillArgs = fillArgs;
+    this.sitePaths = sitePaths;
+    this.schema = schema;
+    String protocol = getRequiredConfigOption(cfg, "protocol");
+    String hostname = getRequiredConfigOption(cfg, "hostname");
+    String port = getRequiredConfigOption(cfg, "port");
+
+    this.indexName =
+        firstNonNull(cfg.getString("index", null, "name"), DEFAULT_INDEX_NAME);
+
+    // By default Elasticsearch has a 1s delay before changes are available in
+    // the index.  Setting refresh(true) on calls to the index makes the index
+    // refresh immediately.
+    //
+    // Discovery should be disabled during test mode to prevent spurious
+    // connection failures caused by the client starting up and being ready
+    // before the test node.
+    //
+    // This setting should only be set to true during testing, and is not
+    // documented.
+    this.refresh = cfg.getBoolean("index", "elasticsearch", "test", false);
+
+    String url = buildUrl(protocol, hostname, port);
+    JestClientFactory factory = new JestClientFactory();
+    factory.setHttpClientConfig(new HttpClientConfig
+        .Builder(url)
+        .multiThreaded(true)
+        .discoveryEnabled(!refresh)
+        .discoveryFrequency(1L, TimeUnit.MINUTES)
+        .build());
+    client = (JestHttpClient) factory.getObject();
+  }
+
+  @Override
+  public Schema<V> getSchema() {
+    return schema;
+  }
+
+  @Override
+  public void close() {
+    client.shutdownClient();
+  }
+
+  @Override
+  public void markReady(boolean ready) throws IOException {
+    IndexUtils.setReady(sitePaths, indexName, schema.getVersion(), ready);
+  }
+
+  @Override
+  public void delete(K c) throws IOException {
+    Bulk bulk = addActions(new Bulk.Builder(), c).refresh(refresh).build();
+    JestResult result = client.execute(bulk);
+    if (!result.isSucceeded()) {
+      throw new IOException(String.format(
+          "Failed to delete change %s in index %s: %s", c, indexName,
+          result.getErrorMessage()));
+    }
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+    // Delete the index, if it exists.
+    JestResult result = client.execute(
+        new IndicesExists.Builder(indexName).build());
+    if (result.isSucceeded()) {
+      result = client.execute(
+          new DeleteIndex.Builder(indexName).build());
+      if (!result.isSucceeded()) {
+        throw new IOException(String.format(
+            "Failed to delete index %s: %s", indexName,
+            result.getErrorMessage()));
+      }
+    }
+
+    // Recreate the index.
+    result = client.execute(
+        new CreateIndex.Builder(indexName).settings(getMappings()).build());
+    if (!result.isSucceeded()) {
+      String error = String.format("Failed to create index %s: %s",
+          indexName, result.getErrorMessage());
+      throw new IOException(error);
+    }
+  }
+
+  protected abstract Bulk.Builder addActions(Bulk.Builder builder, K c);
+
+  protected abstract String getMappings();
+
+  protected abstract String getId(V v);
+
+  protected Delete delete(String type, K c) {
+    String id = c.toString();
+    return new Delete.Builder(id)
+        .index(indexName)
+        .type(type)
+        .build();
+  }
+
+  protected io.searchbox.core.Index insert(String type, V v) throws IOException {
+    String id = getId(v);
+    String doc = toDoc(v);
+    return new io.searchbox.core.Index.Builder(doc)
+        .index(indexName)
+        .type(type)
+        .id(id)
+        .build();
+  }
+
+  private String toDoc(V v) throws IOException {
+    XContentBuilder builder = jsonBuilder().startObject();
+    for (Values<V> values : schema.buildFields(v, fillArgs)) {
+      String name = values.getField().getName();
+      if (values.getField().isRepeatable()) {
+        builder.array(name, values.getValues());
+      } else {
+        Object element = Iterables.getOnlyElement(values.getValues(), "");
+        if (!(element instanceof String) || !((String) element).isEmpty()) {
+          builder.field(name, element);
+        }
+      }
+    }
+    return builder.endObject().string();
+  }
+
+  private String getRequiredConfigOption(Config cfg, String name) {
+    String option = cfg.getString("index", null, name);
+    checkState(!Strings.isNullOrEmpty(option), "index." + name + " must be supplied");
+    return option;
+  }
+
+  private String buildUrl(String protocol, String hostname, String port) {
+    try {
+      return new URL(protocol, hostname, Integer.parseInt(port), "").toString();
+    } catch (MalformedURLException | NumberFormatException e) {
+      throw new RuntimeException(
+          "Cannot build url to Elasticsearch from values: protocol=" + protocol
+              + " hostname=" + hostname + " port=" + port, e);
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
new file mode 100644
index 0000000..dd272fa
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -0,0 +1,412 @@
+// Copyright (C) 2014 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.elasticsearch;
+
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
+import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.commons.codec.binary.Base64.decodeBase64;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.index.IndexUtils;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Id;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.FieldDef.FillArgs;
+import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeField.ChangeProtoField;
+import com.google.gerrit.server.index.change.ChangeField.PatchSetApprovalProtoField;
+import com.google.gerrit.server.index.change.ChangeField.PatchSetProtoField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+import io.searchbox.client.JestResult;
+import io.searchbox.core.Bulk;
+import io.searchbox.core.Bulk.Builder;
+import io.searchbox.core.Search;
+import io.searchbox.core.search.sort.Sort;
+import io.searchbox.core.search.sort.Sort.Sorting;
+
+/** Secondary index implementation using Elasticsearch. */
+class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
+    implements ChangeIndex {
+  private static final Logger log =
+      LoggerFactory.getLogger(ElasticChangeIndex.class);
+
+  static class ChangeMapping {
+    MappingProperties openChanges;
+    MappingProperties closedChanges;
+
+    ChangeMapping(Schema<ChangeData> schema) {
+      ElasticMapping.Builder mappingBuilder = new ElasticMapping.Builder();
+      for (FieldDef<?, ?> field : schema.getFields().values()) {
+        String name = field.getName();
+        FieldType<?> fieldType = field.getType();
+        if (fieldType == FieldType.EXACT) {
+          mappingBuilder.addExactField(name);
+        } else if (fieldType == FieldType.TIMESTAMP) {
+          mappingBuilder.addTimestamp(name);
+        } else if (fieldType == FieldType.INTEGER
+            || fieldType == FieldType.INTEGER_RANGE
+            || fieldType == FieldType.LONG) {
+          mappingBuilder.addNumber(name);
+        } else if (fieldType == FieldType.PREFIX
+            || fieldType == FieldType.FULL_TEXT
+            || fieldType == FieldType.STORED_ONLY) {
+          mappingBuilder.addString(name);
+        } else {
+          throw new IllegalArgumentException(
+              "Unsupported filed type " + fieldType.getName());
+        }
+      }
+      MappingProperties mapping = mappingBuilder.build();
+      openChanges = mapping;
+      closedChanges = mapping;
+    }
+  }
+
+  static final String OPEN_CHANGES = "open_changes";
+  static final String CLOSED_CHANGES = "closed_changes";
+
+  private final Gson gson;
+  private final ChangeMapping mapping;
+  private final Provider<ReviewDb> db;
+  private final ElasticQueryBuilder queryBuilder;
+  private final ChangeData.Factory changeDataFactory;
+
+  @AssistedInject
+  ElasticChangeIndex(
+      @GerritServerConfig Config cfg,
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      FillArgs fillArgs,
+      SitePaths sitePaths,
+      @Assisted Schema<ChangeData> schema) {
+    super(cfg, fillArgs, sitePaths, schema);
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    mapping = new ChangeMapping(schema);
+
+    this.queryBuilder = new ElasticQueryBuilder();
+    this.gson = new GsonBuilder()
+        .setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
+  }
+
+  private static <T> List<T> decodeProtos(JsonObject doc, String fieldName,
+      ProtobufCodec<T> codec) {
+    return FluentIterable.from(doc.getAsJsonArray(fieldName))
+        .transform(i -> codec.decode(decodeBase64(i.toString())))
+        .toList();
+  }
+
+  @Override
+  public void replace(ChangeData cd) throws IOException {
+    String deleteIndex;
+    String insertIndex;
+
+    try {
+      if (cd.change().getStatus().isOpen()) {
+        insertIndex = OPEN_CHANGES;
+        deleteIndex = CLOSED_CHANGES;
+      } else {
+        insertIndex = CLOSED_CHANGES;
+        deleteIndex = OPEN_CHANGES;
+      }
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+
+    Bulk bulk = new Bulk.Builder()
+        .defaultIndex(indexName)
+        .defaultType("changes")
+        .addAction(insert(insertIndex, cd))
+        .addAction(delete(deleteIndex, cd.getId()))
+        .refresh(refresh)
+        .build();
+    JestResult result = client.execute(bulk);
+    if (!result.isSucceeded()) {
+      throw new IOException(String.format(
+          "Failed to replace change %s in index %s: %s", cd.getId(), indexName,
+          result.getErrorMessage()));
+    }
+  }
+
+  @Override
+  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
+      throws QueryParseException {
+    Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
+    List<String> indexes = Lists.newArrayListWithCapacity(2);
+    if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
+      indexes.add(OPEN_CHANGES);
+    }
+    if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+      indexes.add(CLOSED_CHANGES);
+    }
+    return new QuerySource(indexes, p, opts);
+  }
+
+  @Override
+  protected Builder addActions(Builder builder, Id c) {
+    return builder
+        .addAction(delete(OPEN_CHANGES, c))
+        .addAction(delete(OPEN_CHANGES, c));
+  }
+
+  @Override
+  protected String getMappings() {
+    return gson.toJson(ImmutableMap.of("mappings", mapping));
+  }
+
+  @Override
+  protected String getId(ChangeData cd) {
+    return cd.getId().toString();
+  }
+
+  private class QuerySource implements ChangeDataSource {
+    private final Search search;
+    private final Set<String> fields;
+
+    QuerySource(List<String> types, Predicate<ChangeData> p,
+        QueryOptions opts) throws QueryParseException {
+      List<Sort> sorts = ImmutableList.of(
+          new Sort(ChangeField.UPDATED.getName(), Sorting.DESC),
+          new Sort(ChangeField.LEGACY_ID.getName(), Sorting.DESC));
+      for (Sort sort : sorts) {
+        sort.setIgnoreUnmapped();
+      }
+      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
+      fields = IndexUtils.fields(opts);
+      SearchSourceBuilder searchSource = new SearchSourceBuilder()
+          .query(qb)
+          .from(opts.start())
+          .size(opts.limit())
+          .fields(Lists.newArrayList(fields));
+
+      search = new Search.Builder(searchSource.toString())
+          .addType(types)
+          .addSort(sorts)
+          .addIndex(indexName)
+          .build();
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<ChangeData> read() throws OrmException {
+      try {
+        List<ChangeData> results = Collections.emptyList();
+        JestResult result = client.execute(search);
+        if (result.isSucceeded()) {
+          JsonObject obj = result.getJsonObject().getAsJsonObject("hits");
+          if (obj.get("hits") != null) {
+            JsonArray json = obj.getAsJsonArray("hits");
+            results = Lists.newArrayListWithCapacity(json.size());
+            for (int i = 0; i < json.size(); i++) {
+              results.add(toChangeData(json.get(i)));
+            }
+          }
+        } else {
+          log.error(result.getErrorMessage());
+        }
+        final List<ChangeData> r = Collections.unmodifiableList(results);
+        return new ResultSet<ChangeData>() {
+          @Override
+          public Iterator<ChangeData> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<ChangeData> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+
+    @Override
+    public boolean hasChange() {
+      return false;
+    }
+
+    @Override
+    public String toString() {
+      return search.toString();
+    }
+
+    private ChangeData toChangeData(JsonElement json) {
+      JsonElement sourceElement = json.getAsJsonObject().get("_source");
+      if (sourceElement == null) {
+        sourceElement = json.getAsJsonObject().get("fields");
+      }
+      JsonObject source = sourceElement.getAsJsonObject();
+      JsonElement c = source.get(ChangeField.CHANGE.getName());
+
+      if (c == null) {
+        int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
+        String projectName =
+            source.get(ChangeField.PROJECT.getName()).getAsString();
+        if (projectName == null) {
+          return changeDataFactory.createOnlyWhenNoteDbDisabled(
+              db.get(), new Change.Id(id));
+        }
+        return changeDataFactory.create(
+            db.get(), new Project.NameKey(projectName), new Change.Id(id));
+      }
+
+      ChangeData cd = changeDataFactory.create(db.get(),
+          ChangeProtoField.CODEC.decode(Base64.decodeBase64(c.getAsString())));
+
+      // Patch sets.
+      cd.setPatchSets(decodeProtos(
+          source, ChangeField.PATCH_SET.getName(), PatchSetProtoField.CODEC));
+
+      // Approvals.
+      if (source.get(ChangeField.APPROVAL.getName()) != null) {
+        cd.setCurrentApprovals(decodeProtos(source,
+            ChangeField.APPROVAL.getName(), PatchSetApprovalProtoField.CODEC));
+      } else if (fields.contains(ChangeField.APPROVAL.getName())) {
+        cd.setCurrentApprovals(Collections.emptyList());
+      }
+
+      JsonElement addedElement = source.get(ChangeField.ADDED.getName());
+      JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
+      if (addedElement != null && deletedElement != null) {
+        // Changed lines.
+        int added = addedElement.getAsInt();
+        int deleted = deletedElement.getAsInt();
+        if (added != 0 && deleted != 0) {
+          cd.setChangedLines(added, deleted);
+        }
+      }
+
+      // Mergeable.
+      JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
+      if (mergeableElement != null) {
+        String mergeable = mergeableElement.getAsString();
+        if ("1".equals(mergeable)) {
+          cd.setMergeable(true);
+        } else if ("0".equals(mergeable)) {
+          cd.setMergeable(false);
+        }
+      }
+
+      // Reviewed-by.
+      if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
+        JsonArray reviewedBy =
+            source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
+        if (reviewedBy.size() > 0) {
+          Set<Account.Id> accounts =
+              Sets.newHashSetWithExpectedSize(reviewedBy.size());
+          for (int i = 0; i < reviewedBy.size() ; i++) {
+            int aId = reviewedBy.get(i).getAsInt();
+            if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
+              break;
+            }
+            accounts.add(new Account.Id(aId));
+          }
+          cd.setReviewedBy(accounts);
+        }
+      } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
+        cd.setReviewedBy(Collections.emptySet());
+      }
+
+      if (source.get(ChangeField.REVIEWER.getName()) != null) {
+        cd.setReviewers(
+            ChangeField.parseReviewerFieldValues(FluentIterable
+                .from(
+                    source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
+                .transform(JsonElement::getAsString)));
+      } else if (fields.contains(ChangeField.REVIEWER.getName())) {
+        cd.setReviewers(ReviewerSet.empty());
+      }
+
+      decodeSubmitRecords(source,
+          ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
+          ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
+      decodeSubmitRecords(source,
+          ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
+          ChangeField.SUBMIT_RULE_OPTIONS_LENIENT, cd);
+
+      return cd;
+    }
+
+    private void decodeSubmitRecords(JsonObject doc, String fieldName,
+        SubmitRuleOptions opts, ChangeData out) {
+      JsonArray records = doc.getAsJsonArray(fieldName);
+      if (records == null) {
+        return;
+      }
+      ChangeField.parseSubmitRecords(
+          FluentIterable.from(records)
+              .transform(i -> new String(decodeBase64(i.toString()), UTF_8))
+              .toList(),
+          opts, out);
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
new file mode 100644
index 0000000..e108dca
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2014 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.elasticsearch;
+
+import com.google.gerrit.index.SingleVersionModule;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.lucene.LuceneAccountIndex;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.IndexModule;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.util.Map;
+
+public class ElasticIndexModule extends LifecycleModule {
+  private final int threads;
+  private final Map<String, Integer> singleVersions;
+
+  public static ElasticIndexModule singleVersionWithExplicitVersions(
+      Map<String, Integer> versions, int threads) {
+    return new ElasticIndexModule(versions, threads);
+  }
+
+  public static ElasticIndexModule latestVersionWithOnlineUpgrade() {
+    return new ElasticIndexModule(null, 0);
+  }
+
+  private ElasticIndexModule(Map<String, Integer> singleVersions, int threads) {
+    this.singleVersions = singleVersions;
+    this.threads = threads;
+  }
+
+  @Override
+  protected void configure() {
+    install(
+        new FactoryModuleBuilder()
+            .implement(ChangeIndex.class, ElasticChangeIndex.class)
+            .build(ChangeIndex.Factory.class));
+    install(
+        new FactoryModuleBuilder()
+            // until we implement Elasticsearch index for accounts we need to
+            // use Lucene to make all tests green and Gerrit server to work
+            .implement(AccountIndex.class, LuceneAccountIndex.class)
+            .build(AccountIndex.Factory.class));
+
+    install(new IndexModule(threads));
+    install(new SingleVersionModule(singleVersions));
+  }
+
+  @Provides
+  @Singleton
+  IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
+    return IndexConfig.fromConfig(cfg);
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
new file mode 100644
index 0000000..e3f7e96
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2016 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.elasticsearch;
+
+import com.google.common.collect.ImmutableMap;
+
+import java.util.Map;
+
+class ElasticMapping {
+  static class Builder {
+    private final ImmutableMap.Builder<String, FieldProperties> fields =
+        new ImmutableMap.Builder<>();
+
+    MappingProperties build() {
+      MappingProperties properties = new MappingProperties();
+      properties.properties = fields.build();
+      return properties;
+    }
+
+    Builder addExactField(String name) {
+      FieldProperties key = new FieldProperties("string");
+      key.index = "not_analyzed";
+      FieldProperties properties = new FieldProperties("string");
+      properties.fields = ImmutableMap.of("key", key);
+      fields.put(name, properties);
+      return this;
+    }
+
+    Builder addTimestamp(String name) {
+      FieldProperties properties = new FieldProperties("date");
+      properties.type = "date";
+      properties.format = "dateOptionalTime";
+      fields.put(name, properties);
+      return this;
+    }
+
+    Builder addNumber(String name) {
+      fields.put(name, new FieldProperties("long"));
+      return this;
+    }
+
+    Builder addString(String name) {
+      fields.put(name, new FieldProperties("string"));
+      return this;
+    }
+
+    Builder add(String name, String type) {
+      fields.put(name, new FieldProperties(type));
+      return this;
+    }
+  }
+
+  static class MappingProperties {
+    Map<String, FieldProperties> properties;
+  }
+
+  static class FieldProperties {
+    String type;
+    String index;
+    String format;
+    Map<String, FieldProperties> fields;
+
+    FieldProperties(String type) {
+      this.type = type;
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
new file mode 100644
index 0000000..51b14a4
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
@@ -0,0 +1,181 @@
+// Copyright (C) 2014 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.elasticsearch;
+
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.IntegerRangePredicate;
+import com.google.gerrit.server.index.RegexPredicate;
+import com.google.gerrit.server.index.TimestampRangePredicate;
+import com.google.gerrit.server.query.AndPredicate;
+import com.google.gerrit.server.query.NotPredicate;
+import com.google.gerrit.server.query.OrPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.AfterPredicate;
+
+import org.apache.lucene.search.BooleanQuery;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
+import org.joda.time.DateTime;
+
+public class ElasticQueryBuilder {
+
+  protected <T> QueryBuilder toQueryBuilder(Predicate<T> p)
+      throws QueryParseException {
+    if (p instanceof AndPredicate) {
+      return and(p);
+    } else if (p instanceof OrPredicate) {
+      return or(p);
+    } else if (p instanceof NotPredicate) {
+      return not(p);
+    } else if (p instanceof IndexPredicate) {
+      return fieldQuery((IndexPredicate<T>) p);
+    } else {
+      throw new QueryParseException("cannot create query for index: " + p);
+    }
+  }
+
+  private <T> BoolQueryBuilder and(Predicate<T> p)
+      throws QueryParseException {
+    try {
+      BoolQueryBuilder b = QueryBuilders.boolQuery();
+      for (Predicate<T> c : p.getChildren()) {
+        b.must(toQueryBuilder(c));
+      }
+      return b;
+    } catch (BooleanQuery.TooManyClauses e) {
+      throw new QueryParseException("cannot create query for index: " + p, e);
+    }
+  }
+
+  private <T> BoolQueryBuilder or(Predicate<T> p)
+      throws QueryParseException {
+    try {
+      BoolQueryBuilder q = QueryBuilders.boolQuery();
+      for (Predicate<T> c : p.getChildren()) {
+        q.should(toQueryBuilder(c));
+      }
+      return q;
+    } catch (BooleanQuery.TooManyClauses e) {
+      throw new QueryParseException("cannot create query for index: " + p, e);
+    }
+  }
+
+  private <T> QueryBuilder not(Predicate<T> p)
+      throws QueryParseException {
+    Predicate<T> n = p.getChild(0);
+    if (n instanceof TimestampRangePredicate) {
+      return notTimestamp((TimestampRangePredicate<T>) n);
+    }
+
+    // Lucene does not support negation, start with all and subtract.
+    BoolQueryBuilder q = QueryBuilders.boolQuery();
+    q.must(QueryBuilders.matchAllQuery());
+    q.mustNot(toQueryBuilder(n));
+    return q;
+  }
+
+  private <T> QueryBuilder fieldQuery(IndexPredicate<T> p)
+      throws QueryParseException {
+    FieldType<?> type = p.getType();
+    FieldDef<?,?> field = p.getField();
+    String name = field.getName();
+    String value = p.getValue();
+
+    if (type == FieldType.INTEGER) {
+      // QueryBuilder encodes integer fields as prefix coded bits,
+      // which elasticsearch's queryString can't handle.
+      // Create integer terms with string representations instead.
+      return QueryBuilders.termQuery(name, value);
+    } else if (type == FieldType.INTEGER_RANGE) {
+      return intRangeQuery(p);
+    } else if (type == FieldType.TIMESTAMP) {
+      return timestampQuery(p);
+    } else if (type == FieldType.EXACT) {
+      return exactQuery(p);
+    } else if (type == FieldType.PREFIX) {
+      return QueryBuilders.matchPhrasePrefixQuery(name, value);
+    } else if (type == FieldType.FULL_TEXT) {
+      return QueryBuilders.matchPhraseQuery(name, value);
+    } else {
+      throw FieldType.badFieldType(p.getType());
+    }
+  }
+
+  private <T> QueryBuilder intRangeQuery(IndexPredicate<T> p)
+      throws QueryParseException {
+    if (p instanceof IntegerRangePredicate) {
+      IntegerRangePredicate<T> r = (IntegerRangePredicate<T>) p;
+      int minimum = r.getMinimumValue();
+      int maximum = r.getMaximumValue();
+      if (minimum == maximum) {
+        // Just fall back to a standard integer query.
+        return QueryBuilders.termQuery(p.getField().getName(), minimum);
+      }
+      return QueryBuilders.rangeQuery(p.getField().getName())
+          .gte(minimum)
+          .lte(maximum);
+    }
+    throw new QueryParseException("not an integer range: " + p);
+  }
+
+  private <T> QueryBuilder notTimestamp(TimestampRangePredicate<T> r)
+      throws QueryParseException {
+    if (r.getMinTimestamp().getTime() == 0) {
+      return QueryBuilders.rangeQuery(r.getField().getName())
+          .gt(new DateTime(r.getMaxTimestamp().getTime()));
+    }
+    throw new QueryParseException("cannot negate: " + r);
+  }
+
+  private <T> QueryBuilder timestampQuery(IndexPredicate<T> p)
+      throws QueryParseException {
+    if (p instanceof TimestampRangePredicate) {
+      TimestampRangePredicate<T> r =
+          (TimestampRangePredicate<T>) p;
+      if (p instanceof AfterPredicate) {
+        return QueryBuilders.rangeQuery(r.getField().getName())
+            .gte(new DateTime(r.getMinTimestamp().getTime()));
+      }
+      return QueryBuilders.rangeQuery(r.getField().getName())
+          .gte(new DateTime(r.getMinTimestamp().getTime()))
+          .lte(new DateTime(r.getMaxTimestamp().getTime()));
+    }
+    throw new QueryParseException("not a timestamp: " + p);
+  }
+
+  private <T> QueryBuilder exactQuery(IndexPredicate<T> p){
+    String name = p.getField().getName();
+    String value = p.getValue();
+
+    if (value.isEmpty()) {
+      return new BoolQueryBuilder().mustNot(QueryBuilders.existsQuery(name));
+    } else if (p instanceof RegexPredicate) {
+      if (value.startsWith("^")) {
+        value = value.substring(1);
+      }
+      if (value.endsWith("$") && !value.endsWith("\\$")
+          && !value.endsWith("\\\\$")) {
+        value = value.substring(0, value.length() - 1);
+      }
+      return QueryBuilders.regexpQuery(name + ".key", value);
+    } else {
+      return QueryBuilders.termQuery(name + ".key", value);
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
new file mode 100644
index 0000000..ed96a67
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
@@ -0,0 +1,179 @@
+// Copyright (C) 2014 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.elasticsearch;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.elasticsearch.ElasticChangeIndex.CLOSED_CHANGES;
+import static com.google.gerrit.elasticsearch.ElasticChangeIndex.OPEN_CHANGES;
+
+import com.google.common.base.Strings;
+import com.google.common.io.Files;
+import com.google.gerrit.elasticsearch.ElasticChangeIndex.ChangeMapping;
+import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.action.admin.cluster.node.info.NodesInfoRequest;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.node.Node;
+import org.elasticsearch.node.NodeBuilder;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+
+public class ElasticQueryChangesTest extends AbstractQueryChangesTest {
+  private static final Gson gson = new GsonBuilder()
+      .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+      .create();
+  private static Node node;
+  private static String port;
+  private static File elasticDir;
+
+  static class NodeInfo {
+    String httpAddress;
+  }
+
+  static class Info {
+    Map<String, NodeInfo> nodes;
+  }
+
+  @BeforeClass
+  public static void startIndexService()
+      throws InterruptedException, ExecutionException {
+    if (node != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+    elasticDir = Files.createTempDir();
+    Path elasticDirPath = elasticDir.toPath();
+    Settings settings = Settings.settingsBuilder()
+        .put("cluster.name", "gerrit")
+        .put("node.name", "Gerrit Elasticsearch Test Node")
+        .put("node.local", true)
+        .put("discovery.zen.ping.multicast.enabled", false)
+        .put("index.store.fs.memory.enabled", true)
+        .put("index.gateway.type", "none")
+        .put("index.max_result_window", Integer.MAX_VALUE)
+        .put("gateway.type", "default")
+        .put("http.port", 0)
+        .put("discovery.zen.ping.unicast.hosts", "[\"localhost\"]")
+        .put("path.home", elasticDirPath.toAbsolutePath())
+        .put("path.data", elasticDirPath.resolve("data").toAbsolutePath())
+        .put("path.work", elasticDirPath.resolve("work").toAbsolutePath())
+        .put("path.logs", elasticDirPath.resolve("logs").toAbsolutePath())
+        .put("transport.tcp.connect_timeout", "60s")
+        .build();
+
+    // Start the node
+    node = NodeBuilder.nodeBuilder()
+        .settings(settings)
+        .node();
+
+    // Wait for it to be ready
+    node.client()
+        .admin()
+        .cluster()
+        .prepareHealth()
+        .setWaitForYellowStatus()
+        .execute()
+        .actionGet();
+
+    createIndexes();
+
+    assertThat(node.isClosed()).isFalse();
+    port = getHttpPort();
+  }
+
+  @After
+  public void cleanupIndex() {
+    node.client().admin().indices().prepareDelete("gerrit").execute();
+    createIndexes();
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (node != null) {
+      node.close();
+      node = null;
+    }
+    if (elasticDir != null && elasticDir.delete()) {
+      elasticDir = null;
+    }
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    elasticsearchConfig.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
+    elasticsearchConfig.setString("index", null, "protocol", "http");
+    elasticsearchConfig.setString("index", null, "hostname", "localhost");
+    elasticsearchConfig.setString("index", null, "port", port);
+    elasticsearchConfig.setString("index", null, "name", "gerrit");
+    elasticsearchConfig.setBoolean("index", "elasticsearch", "test", true);
+    return Guice.createInjector(
+        new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+
+  private static void createIndexes() {
+    ChangeMapping openChangesMapping =
+        new ChangeMapping(ChangeSchemaDefinitions.INSTANCE.getLatest());
+    ChangeMapping closedChangesMapping =
+        new ChangeMapping(ChangeSchemaDefinitions.INSTANCE.getLatest());
+    openChangesMapping.closedChanges = null;
+    closedChangesMapping.openChanges = null;
+    node.client()
+        .admin()
+        .indices()
+        .prepareCreate("gerrit")
+        .addMapping(OPEN_CHANGES, gson.toJson(openChangesMapping))
+        .addMapping(CLOSED_CHANGES, gson.toJson(closedChangesMapping))
+        .execute()
+        .actionGet();
+  }
+
+  private static String getHttpPort()
+      throws InterruptedException, ExecutionException {
+    String nodes = node.client().admin().cluster()
+        .nodesInfo(new NodesInfoRequest("*")).get().toString();
+    Gson gson = new GsonBuilder()
+        .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+        .create();
+    Info info = gson.fromJson(nodes, Info.class);
+
+    checkState(info.nodes != null && info.nodes.size() == 1);
+    Iterator<NodeInfo> values = info.nodes.values().iterator();
+    String httpAddress = values.next().httpAddress;
+
+    checkState(
+        !Strings.isNullOrEmpty(httpAddress) && httpAddress.indexOf(':') > 0);
+    return httpAddress.substring(httpAddress.indexOf(':') + 1,
+        httpAddress.length());
+  }
+}
diff --git a/gerrit-extension-api/BUCK b/gerrit-extension-api/BUCK
index 61cd406..a0e7495 100644
--- a/gerrit-extension-api/BUCK
+++ b/gerrit-extension-api/BUCK
@@ -67,14 +67,13 @@
     '//lib:truth',
     '//lib/guice:guice',
   ],
-  source_under_test = [':api'],
 )
 
 java_doc(
   name = 'extension-api-javadoc',
   title = 'Gerrit Review Extension API Documentation',
   pkgs = ['com.google.gerrit.extensions'],
-  paths = ['src/main/java'],
+  source_jar = ':extension-api-src',
   srcs = SRCS,
   deps = [
     '//lib:guava',
diff --git a/gerrit-extension-api/BUILD b/gerrit-extension-api/BUILD
index 4a5cfe3..6f4df01 100644
--- a/gerrit-extension-api/BUILD
+++ b/gerrit-extension-api/BUILD
@@ -1,3 +1,5 @@
+load('//lib:guava.bzl', 'GUAVA_DOC_URL')
+load('//lib/jgit:jgit.bzl', 'JGIT_DOC_URL')
 load('//tools/bzl:gwt.bzl', 'gwt_module')
 
 SRC = 'src/main/java/com/google/gerrit/extensions/'
@@ -44,3 +46,14 @@
   ],
   visibility = ['//visibility:public'],
 )
+
+load('//tools/bzl:javadoc.bzl', 'java_doc')
+
+java_doc(
+  name = 'extension-api-javadoc',
+  title = 'Gerrit Review Extension API Documentation',
+  libs = [':api'],
+  pkgs = ['com.google.gerrit.extensions'],
+  external_docs = [JGIT_DOC_URL, GUAVA_DOC_URL],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-extension-api/pom.xml b/gerrit-extension-api/pom.xml
index 11c420f..7375893 100644
--- a/gerrit-extension-api/pom.xml
+++ b/gerrit-extension-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>2.13.2</version>
+  <version>2.14-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index c1cb3ec..9765bbf 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -34,6 +34,9 @@
 public interface AccountApi {
   AccountInfo get() throws RestApiException;
 
+  boolean getActive() throws RestApiException;
+  void setActive(boolean active) throws RestApiException;
+
   String getAvatarUrl(int size) throws RestApiException;
 
   GeneralPreferencesInfo getPreferences() throws RestApiException;
@@ -85,6 +88,16 @@
     }
 
     @Override
+    public boolean getActive() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setActive(boolean active) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public String getAvatarUrl(int size) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
index ca61b1d..4c535d4 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
@@ -24,6 +24,7 @@
   public String reviewer;
   public Boolean confirmed;
   public ReviewerState state;
+  public NotifyHandling notify;
 
   public boolean confirmed() {
     return (confirmed != null) ? confirmed : false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java
index 9bcabc3..61b5b85 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.api.changes;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.extensions.restapi.DefaultInput;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
+public class AssigneeInput {
+  @DefaultInput
+  public String assignee;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index f656c2d..629ad97 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -15,9 +15,11 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -94,9 +96,15 @@
    */
   ChangeApi revert(RevertInput in) throws RestApiException;
 
+  /** Create a merge patch set for the change. */
+  ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException;
+
   List<ChangeInfo> submittedTogether() throws RestApiException;
   SubmittedTogetherInfo submittedTogether(
       EnumSet<SubmittedTogetherOption> options) throws RestApiException;
+  SubmittedTogetherInfo submittedTogether(
+      EnumSet<ListChangesOption> listOptions,
+      EnumSet<SubmittedTogetherOption> submitOptions) throws RestApiException;
 
   /**
    * Publishes a draft change.
@@ -104,7 +112,7 @@
   void publish() throws RestApiException;
 
   /**
-   * Deletes a draft change.
+   * Deletes a change.
    */
   void delete() throws RestApiException;
 
@@ -139,6 +147,26 @@
   Set<String> getHashtags() throws RestApiException;
 
   /**
+   * Set the assignee of a change.
+   */
+  AccountInfo setAssignee(AssigneeInput input) throws RestApiException;
+
+  /**
+   * Get the assignee of a change.
+   */
+  AccountInfo getAssignee() throws RestApiException;
+
+  /**
+   * Get all past assignees.
+  */
+  List<AccountInfo> getPastAssignees() throws RestApiException;
+
+  /**
+   * Delete the assignee of a change.
+   */
+  AccountInfo deleteAssignee() throws RestApiException;
+
+  /**
    * Get all published comments on a change.
    *
    * @return comments in a map keyed by path; comments have the {@code revision}
@@ -326,6 +354,27 @@
     }
 
     @Override
+    public AccountInfo setAssignee(AssigneeInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccountInfo getAssignee() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<AccountInfo> getPastAssignees() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccountInfo deleteAssignee() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> comments() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -360,5 +409,18 @@
         EnumSet<SubmittedTogetherOption> options) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public SubmittedTogetherInfo submittedTogether(
+        EnumSet<ListChangesOption> a,
+        EnumSet<SubmittedTogetherOption> b) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ChangeInfo createMergePatchSet(MergePatchSetInput in)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 7ae7ef1..2e1bb13 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -17,4 +17,5 @@
 public class CherryPickInput {
   public String message;
   public String destination;
+  public Integer parent;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java
similarity index 61%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java
index 9bcabc3..6af0dbb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.api.changes;
 
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
+/** Input passed to {@code DELETE /changes/[id]/reviewers/[id]}. */
+public class DeleteReviewerInput {
+  /** Who to send email notifications to after the reviewer is deleted. */
+  public NotifyHandling notify = NotifyHandling.ALL;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/PublishChangeEditInput.java
similarity index 61%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/PublishChangeEditInput.java
index 9bcabc3..fa6f18f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/PublishChangeEditInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.api.changes;
 
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
-}
+/** Input passed to {@code POST /changes/[id]/edit:publish/}. */
+public class PublishChangeEditInput {
+  /** Who to send email notifications to after the change edit is published. */
+  public NotifyHandling notify = NotifyHandling.ALL;
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index cbe16ed..472559b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -34,6 +34,7 @@
 
   public Map<String, Short> labels;
   public Map<String, List<CommentInput>> comments;
+  public Map<String, List<RobotCommentInput>> robotComments;
 
   /**
    * If true require all labels to be within the user's permitted ranges based
@@ -48,8 +49,11 @@
   /**
    * How to process draft comments already in the database that were not also
    * described in this input request.
+   * <p>
+   * Defaults to DELETE, unless {@link #onBehalfOf} is set, in which case it
+   * defaults to KEEP and any other value is disallowed.
    */
-  public DraftHandling drafts = DraftHandling.DELETE;
+  public DraftHandling drafts;
 
   /** Who to send email notifications to after review is stored. */
   public NotifyHandling notify = NotifyHandling.ALL;
@@ -94,6 +98,13 @@
   public static class CommentInput extends Comment {
   }
 
+  public static class RobotCommentInput extends CommentInput {
+    public String robotId;
+    public String robotRunId;
+    public String url;
+    public Map<String, String> properties;
+  }
+
   public ReviewInput message(String msg) {
     message = msg != null && !msg.isEmpty() ? msg : null;
     return this;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
index d1f09e8..79cc12e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
@@ -25,6 +25,7 @@
   void deleteVote(String label) throws RestApiException;
   void deleteVote(DeleteVoteInput input) throws RestApiException;
   void remove() throws RestApiException;
+  void remove(DeleteReviewerInput input) throws RestApiException;
 
   /**
    * A default implementation which allows source compatibility
@@ -50,5 +51,10 @@
     public void remove() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void remove(DeleteReviewerInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 2731476..8ada55a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -17,8 +17,10 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -35,6 +37,7 @@
 
   void submit() throws RestApiException;
   void submit(SubmitInput in) throws RestApiException;
+  BinaryResult submitPreview() throws RestApiException;
   void publish() throws RestApiException;
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
   ChangeApi rebase() throws RestApiException;
@@ -52,26 +55,57 @@
   MergeableInfo mergeableOtherBranches() throws RestApiException;
 
   Map<String, List<CommentInfo>> comments() throws RestApiException;
+  Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException;
   Map<String, List<CommentInfo>> drafts() throws RestApiException;
 
   List<CommentInfo> commentsAsList() throws RestApiException;
   List<CommentInfo> draftsAsList() throws RestApiException;
+  List<RobotCommentInfo> robotCommentsAsList() throws RestApiException;
 
   DraftApi createDraft(DraftInput in) throws RestApiException;
   DraftApi draft(String id) throws RestApiException;
 
   CommentApi comment(String id) throws RestApiException;
+  RobotCommentApi robotComment(String id) throws RestApiException;
 
   /**
    * Returns patch of revision.
    */
   BinaryResult patch() throws RestApiException;
+  BinaryResult patch(String path) throws RestApiException;
 
   Map<String, ActionInfo> actions() throws RestApiException;
 
   SubmitType submitType() throws RestApiException;
   SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException;
 
+  MergeListRequest getMergeList() throws RestApiException;
+
+  abstract class MergeListRequest {
+    private boolean addLinks;
+    private int uninterestingParent = 1;
+
+    public abstract List<CommitInfo> get() throws RestApiException;
+
+    public MergeListRequest withLinks() {
+      this.addLinks = true;
+      return this;
+    }
+
+    public MergeListRequest withUninterestingParent(int uninterestingParent) {
+      this.uninterestingParent = uninterestingParent;
+      return this;
+    }
+
+    public boolean getAddLinks() {
+      return addLinks;
+    }
+
+    public int getUninterestingParent() {
+      return uninterestingParent;
+    }
+  }
+
   /**
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
@@ -168,6 +202,12 @@
     }
 
     @Override
+    public Map<String, List<RobotCommentInfo>> robotComments()
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public List<CommentInfo> commentsAsList() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -178,6 +218,12 @@
     }
 
     @Override
+    public List<RobotCommentInfo> robotCommentsAsList()
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -198,11 +244,21 @@
     }
 
     @Override
+    public RobotCommentApi robotComment(String id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public BinaryResult patch() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public BinaryResult patch(String path) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, ActionInfo> actions() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -213,9 +269,19 @@
     }
 
     @Override
+    public BinaryResult submitPreview() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public SubmitType testSubmitType(TestSubmitRuleInput in)
         throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public MergeListRequest getMergeList() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java
new file mode 100644
index 0000000..e1ed107
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2016 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.extensions.api.changes;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface RobotCommentApi {
+  RobotCommentInfo get() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility
+   * when adding new methods to the interface.
+   **/
+  class NotImplemented implements RobotCommentApi {
+    @Override
+    public RobotCommentInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
index 8649e91f..e2cab4d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
@@ -16,15 +16,5 @@
 
 /** Output options available for submitted_together requests. */
 public enum SubmittedTogetherOption {
-  NON_VISIBLE_CHANGES(0);
-
-  private final int value;
-
-  SubmittedTogetherOption(int v) {
-    value = v;
-  }
-
-  public int getValue() {
-    return value;
-  }
+  NON_VISIBLE_CHANGES;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
index a43c29f..1e5c95e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
@@ -25,6 +26,8 @@
    */
   String getVersion() throws RestApiException;
 
+  ServerInfo getInfo() throws RestApiException;
+
   GeneralPreferencesInfo getDefaultPreferences() throws RestApiException;
   GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in)
       throws RestApiException;
@@ -43,6 +46,11 @@
     }
 
     @Override
+    public ServerInfo getInfo() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public GeneralPreferencesInfo getDefaultPreferences()
         throws RestApiException {
       throw new NotImplementedException();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AccountFieldName.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AccountFieldName.java
index 9bcabc3..07d9f37 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AccountFieldName.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.client;
 
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
-}
+public enum AccountFieldName {
+  FULL_NAME, USER_NAME, REGISTER_NEW_EMAIL
+}
\ No newline at end of file
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AuthType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AuthType.java
similarity index 91%
rename from gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AuthType.java
rename to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AuthType.java
index 38a78ba..2056e25 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AuthType.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AuthType.java
@@ -12,13 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.reviewdb.client;
+package com.google.gerrit.extensions.client;
 
 public enum AuthType {
-  /** Login relies upon the OpenID standard: {@link "http://openid.net/"} */
+  /** Login relies upon the <a href="http://openid.net/">OpenID standard</a> */
   OPENID,
 
-  /** Login relies upon the OpenID standard: {@link "http://openid.net/"} in Single Sign On mode */
+  /** Login relies upon the <a href="http://openid.net/">OpenID standard</a> in Single Sign On mode */
   OPENID_SSO,
 
   /**
@@ -49,7 +49,7 @@
    * Jetty's SSL channel to request client's SSL certificate. For this
    * authentication to work a Gerrit administrator has to import the root
    * certificate of the trust chain used to issue the client's certificate
-   * into the <review-site>/etc/keystore.
+   * into the &lt;review-site&gt;/etc/keystore.
    * <p>
    * After the authentication is done Gerrit will obtain basic user
    * registration (name and email) from LDAP, and some group memberships.
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
index 7c8a3e8..78ffb82 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
@@ -59,6 +59,13 @@
     }
   }
 
+  public short side() {
+    if (side == Side.PARENT) {
+      return (short) (parent == null ? 0 : -parent.shortValue());
+    }
+    return 1;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (this == o) {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index d246996..3f5fa31 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -22,6 +22,9 @@
   /** Default tab size. */
   public static final int DEFAULT_TAB_SIZE = 8;
 
+  /** Default font size. */
+  public static final int DEFAULT_FONT_SIZE = 12;
+
   /** Default line length. */
   public static final int DEFAULT_LINE_LENGTH = 100;
 
@@ -41,6 +44,7 @@
 
   public Integer context;
   public Integer tabSize;
+  public Integer fontSize;
   public Integer lineLength;
   public Integer cursorBlinkRate;
   public Boolean expandAllComments;
@@ -68,6 +72,7 @@
     DiffPreferencesInfo i = new DiffPreferencesInfo();
     i.context = DEFAULT_CONTEXT;
     i.tabSize = DEFAULT_TAB_SIZE;
+    i.fontSize = DEFAULT_FONT_SIZE;
     i.lineLength = DEFAULT_LINE_LENGTH;
     i.cursorBlinkRate = 0;
     i.ignoreWhitespace = Whitespace.IGNORE_NONE;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 9754f12..8d82e3a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -83,6 +83,25 @@
     DISABLED
   }
 
+  public enum DefaultBase {
+    AUTO_MERGE(null),
+    FIRST_PARENT(-1);
+
+    private final String base;
+
+    DefaultBase(String base) {
+      this.base = base;
+    }
+
+    DefaultBase(int base) {
+      this(Integer.toString(base));
+    }
+
+    public String getBase() {
+      return base;
+    }
+  }
+
   public enum TimeFormat {
     /** 12-hour clock: 1:15 am, 2:13 pm */
     HHMM_12("h:mm a"),
@@ -113,6 +132,7 @@
   public DownloadCommand downloadCommand;
   public DateFormat dateFormat;
   public TimeFormat timeFormat;
+  public Boolean highlightAssigneeInChangeTable;
   public Boolean relativeDateInChangeTable;
   public DiffView diffView;
   public Boolean sizeBarInChangeTable;
@@ -123,6 +143,7 @@
   public List<MenuItem> my;
   public Map<String, String> urlAliases;
   public EmailStrategy emailStrategy;
+  public DefaultBase defaultBaseForMerges;
 
   public boolean isShowInfoInReviewCategory() {
     return getReviewCategoryStrategy() != ReviewCategoryStrategy.NONE;
@@ -174,12 +195,14 @@
     p.downloadCommand = DownloadCommand.CHECKOUT;
     p.dateFormat = DateFormat.STD;
     p.timeFormat = TimeFormat.HHMM_12;
+    p.highlightAssigneeInChangeTable = true;
     p.relativeDateInChangeTable = false;
     p.diffView = DiffView.SIDE_BY_SIDE;
     p.sizeBarInChangeTable = true;
     p.legacycidInChangeTable = false;
     p.muteCommonPathPrefixes = true;
     p.signedOffBy = false;
+    p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
     return p;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java
index 9bcabc3..6450b0d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.client;
 
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
+public enum GitBasicAuthPolicy {
+  HTTP,
+  LDAP,
+  HTTP_LDAP
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java
index 8b6c5e6..787725c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -69,7 +69,10 @@
   PUSH_CERTIFICATES(18),
 
   /** Include change's reviewer updates. */
-  REVIEWER_UPDATES(19);
+  REVIEWER_UPDATES(19),
+
+  /** Set the submittable boolean. */
+  SUBMITTABLE(20);
 
   private final int value;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java
index fcfeb01..b52e89a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java
@@ -18,6 +18,7 @@
   FAST_FORWARD_ONLY,
   MERGE_IF_NECESSARY,
   REBASE_IF_NECESSARY,
+  REBASE_ALWAYS,
   MERGE_ALWAYS,
   CHERRY_PICK
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/UiType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/UiType.java
new file mode 100644
index 0000000..0d9df39
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/UiType.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2016 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.extensions.client;
+
+public enum UiType {
+  NONE,
+  GWT,
+  POLYGERRIT;
+
+  public static UiType parse(String str) {
+    if (str != null) {
+      for (UiType type : UiType.values()) {
+        if (type.name().equalsIgnoreCase(str)) {
+          return type;
+        }
+      }
+    }
+    return null;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java
index 6ec5b1d..4242fcd 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java
@@ -18,4 +18,5 @@
   public String name;
   public String description;
   public String url;
+  public GroupInfo autoVerifyGroup;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index 6d28dbc..d59e813 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -20,6 +20,7 @@
   public String tag;
   public Integer value;
   public Timestamp date;
+  public Boolean postSubmit;
 
   public ApprovalInfo(Integer id) {
     super(id);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java
new file mode 100644
index 0000000..0a066c6
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2016 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.extensions.common;
+
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
+
+import java.util.List;
+
+public class AuthInfo {
+  public AuthType authType;
+  public Boolean useContributorAgreements;
+  public List<AgreementInfo> contributorAgreements;
+  public List<AccountFieldName> editableAccountFields;
+  public String loginUrl;
+  public String loginText;
+  public String switchAccountUrl;
+  public String registerUrl;
+  public String registerText;
+  public String editFullNameUrl;
+  public String httpPasswordUrl;
+  public Boolean isGitBasicAuth;
+  public GitBasicAuthPolicy gitBasicAuthPolicy;
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
new file mode 100644
index 0000000..963edcd
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 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.extensions.common;
+
+public class ChangeConfigInfo {
+  public Boolean allowBlame;
+  public Boolean showAssignee;
+  public Boolean allowDrafts;
+  public int largeChange;
+  public String replyLabel;
+  public String replyTooltip;
+  public int updateDelay;
+  public Boolean submitWholeTopic;
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 003ab24..ba22094 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -28,6 +28,7 @@
   public String project;
   public String branch;
   public String topic;
+  public AccountInfo assignee;
   public Collection<String> hashtags;
   public String changeId;
   public String subject;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadInfo.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadInfo.java
index 9bcabc3..180e2d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.reviewdb.client.Account;
+import java.util.List;
+import java.util.Map;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
-}
+public class DownloadInfo {
+  public Map<String, DownloadSchemeInfo> schemes;
+  public List<String> archives;
+}
\ No newline at end of file
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java
similarity index 62%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java
index 9bcabc3..0e8ad65 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.reviewdb.client.Account;
+import java.util.Map;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
-}
+public class DownloadSchemeInfo {
+  public String url;
+  public Boolean isAuthRequired;
+  public Boolean isAuthSupported;
+  public Map<String, String> commands;
+  public Map<String, String> cloneCommands;
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GerritInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GerritInfo.java
new file mode 100644
index 0000000..0c10ec7
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2016 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.extensions.common;
+
+import com.google.gerrit.extensions.client.UiType;
+
+import java.util.Set;
+
+public class GerritInfo {
+  public String allProjects;
+  public String allUsers;
+  public Boolean docSearch;
+  public String docUrl;
+  public Boolean editGpgKeys;
+  public String reportBugUrl;
+  public String reportBugText;
+  public Set<UiType> webUis;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
index 9bcabc3..263b6c4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
+public class MergePatchSetInput {
+  public String subject;
+  public boolean inheritParent;
+  public MergeInput merge;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
index 9bcabc3..845f7cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.reviewdb.client.Account;
+import java.util.List;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
-}
+public class PluginConfigInfo {
+  public Boolean hasAvatars;
+  public List<String> jsResourcePaths;
+}
\ No newline at end of file
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReceiveInfo.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReceiveInfo.java
index 9bcabc3..e66c242 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReceiveInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
-}
+public class ReceiveInfo {
+  public Boolean enableSignedPush;
+}
\ No newline at end of file
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
index 9bcabc3..9028a1d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.reviewdb.client.Account;
+import java.util.Map;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
+public class RobotCommentInfo extends CommentInfo {
+  public String robotId;
+  public String robotRunId;
+  public String url;
+  public Map<String, String> properties;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java
new file mode 100644
index 0000000..3dd8368
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 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.extensions.common;
+
+import java.util.Map;
+
+public class ServerInfo {
+  public AuthInfo auth;
+  public ChangeConfigInfo change;
+  public DownloadInfo download;
+  public GerritInfo gerrit;
+  public Boolean noteDbEnabled;
+  public PluginConfigInfo plugin;
+  public SshdInfo sshd;
+  public SuggestInfo suggest;
+  public Map<String, String> urlAliases;
+  public UserConfigInfo user;
+  public ReceiveInfo receive;
+}
\ No newline at end of file
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshdInfo.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshdInfo.java
index 9bcabc3..98d650c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshdInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
-}
+public class SshdInfo {
+}
\ No newline at end of file
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestInfo.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestInfo.java
index 9bcabc3..5b0dcbe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
-}
+public class SuggestInfo {
+  public int from;
+}
\ No newline at end of file
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/UserConfigInfo.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/UserConfigInfo.java
index 9bcabc3..5010689 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/UserConfigInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
-}
+public class UserConfigInfo {
+  public String anonymousCowardName;
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java
index d9a34bf..4dd8f02 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.extensions.webui.WebLink.Target;
+
 public class WebLinkInfo {
   public String name;
   public String imageUrl;
@@ -26,4 +28,8 @@
     this.url = url;
     this.target = target;
   }
+
+  public WebLinkInfo(String name, String imageUrl, String url) {
+    this(name, imageUrl, url, Target.SELF);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java
new file mode 100644
index 0000000..022640c
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 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.extensions.events;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a change assignee is changed. */
+@ExtensionPoint
+public interface AssigneeChangedListener {
+  interface Event extends ChangeEvent {
+    @Nullable AccountInfo getOldAssignee();
+  }
+
+  void onAssigneeChanged(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
index 40b84a3..d18f3e5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Change is abandoned. */
 @ExtensionPoint
 public interface ChangeAbandonedListener {
   interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getAbandoner();
     String getReason();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
index d0ca6d6..de74a86 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Change is merged. */
 @ExtensionPoint
 public interface ChangeMergedListener {
   interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getMerger();
     /**
      * Represents the merged Revision when the submit strategy is cherry-pick or
      * rebase-if-necessary.
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
index e5f3330..f533339 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Change is restored. */
 @ExtensionPoint
 public interface ChangeRestoredListener {
   interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getRestorer();
     String getReason();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java
index 6c82034..e8388a9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 
 import java.util.Map;
@@ -24,8 +23,6 @@
 @ExtensionPoint
 public interface CommentAddedListener {
   interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getAuthor();
     String getComment();
     Map<String, ApprovalInfo> getApprovals();
     Map<String, ApprovalInfo> getOldApprovals();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
index 3857468..1fc574b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Draft is published. */
 @ExtensionPoint
 public interface DraftPublishedListener {
   interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getPublisher();
   }
 
   void onDraftPublished(Event event);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
index c49b0f3..ad13267 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 import java.util.Collection;
 
@@ -23,8 +22,6 @@
 @ExtensionPoint
 public interface HashtagsEditedListener {
   interface Event extends ChangeEvent {
-    @Deprecated
-    AccountInfo getEditor();
     Collection<String> getHashtags();
     Collection<String> getAddedHashtags();
     Collection<String> getRemovedHashtags();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
index 3cc3fdc..bb4ac9d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
@@ -17,12 +17,14 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.common.AccountInfo;
 
-/** Notified whenever a Reviewer is added to a change. */
+import java.util.List;
+
+/** Notified whenever one or more Reviewers are added to a change. */
 @ExtensionPoint
 public interface ReviewerAddedListener {
   interface Event extends ChangeEvent {
-    AccountInfo getReviewer();
+    List<AccountInfo> getReviewers();
   }
 
-  void onReviewerAdded(Event event);
+  void onReviewersAdded(Event event);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
index 5e4e095..8d148b7 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Change Revision is created. */
 @ExtensionPoint
 public interface RevisionCreatedListener {
   interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getUploader();
   }
 
   void onRevisionCreated(Event event);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java
index 68ba22c..0c36d9d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Change Topic is changed. */
 @ExtensionPoint
 public interface TopicEditedListener {
   interface Event extends ChangeEvent {
-    @Deprecated
-    AccountInfo getEditor();
     String getOldTopic();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BadRequestException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BadRequestException.java
index d5a9c1f..6e79c3a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BadRequestException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BadRequestException.java
@@ -22,4 +22,12 @@
   public BadRequestException(String msg) {
     super(msg);
   }
+
+  /**
+   * @param msg error text for client describing how request is bad.
+   * @param cause cause of this exception.
+   */
+  public BadRequestException(String msg, Throwable cause) {
+    super(msg, cause);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java
index 068d9a0..4fc9ab6 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java
@@ -86,12 +86,6 @@
   }
 
   /** Set the character set used to encode text data and return {@code this}. */
-  @Deprecated
-  public BinaryResult setCharacterEncoding(String encoding) {
-    return setCharacterEncoding(Charset.forName(encoding));
-  }
-
-  /** Set the character set used to encode text data and return {@code this}. */
   public BinaryResult setCharacterEncoding(Charset encoding) {
     characterEncoding = encoding;
     return this;
@@ -235,7 +229,7 @@
     StringResult(String str) {
       super(str.getBytes(UTF_8));
       setContentType("text/plain");
-      setCharacterEncoding(UTF_8.name());
+      setCharacterEncoding(UTF_8);
       this.str = str;
     }
 
diff --git a/gerrit-gpg/BUCK b/gerrit-gpg/BUCK
index 73d9f04..fe93bf8 100644
--- a/gerrit-gpg/BUCK
+++ b/gerrit-gpg/BUCK
@@ -52,6 +52,5 @@
     '//lib/bouncycastle:bcprov',
     '//lib/jgit/org.eclipse.jgit.junit:junit',
   ],
-  source_under_test = [':gpg'],
   visibility = ['//tools/eclipse:classpath'],
 )
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index db6cb7a..c65b114 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -18,11 +18,8 @@
 import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GPGKEY;
 
 import com.google.common.base.CharMatcher;
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Ordering;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
@@ -227,12 +224,12 @@
     return false;
   }
 
-  @SuppressWarnings("unchecked")
   private Iterator<PGPSignature> getSignaturesForId(PGPPublicKey key,
       String userId) {
-    return MoreObjects.firstNonNull(
-        key.getSignaturesForID(userId),
-        Collections.emptyIterator());
+    Iterator<PGPSignature> result = key.getSignaturesForID(userId);
+    return result != null
+        ? result
+        : Collections.emptyIterator();
   }
 
   private Set<String> getAllowedUserIds(IdentifiedUser user) {
@@ -274,9 +271,7 @@
   private static String missingUserIds(Set<String> allowedUserIds) {
     StringBuilder sb = new StringBuilder("Key must contain a valid"
         + " certification for one of the following identities:\n");
-    Iterator<String> sorted = FluentIterable.from(allowedUserIds)
-        .toSortedList(Ordering.natural())
-        .iterator();
+    Iterator<String> sorted = allowedUserIds.stream().sorted().iterator();
     while (sorted.hasNext()) {
       sb.append("  ").append(sorted.next());
       if (sorted.hasNext()) {
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyChecker.java
index e4c81df..66e810c 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -291,7 +291,8 @@
       return null;
     }
 
-    return new RevocationKey(sub.isCritical(), sub.getData());
+    return new RevocationKey(sub.isCritical(), sub.isLongLength(),
+        sub.getData());
   }
 
   private void checkRevocations(PGPPublicKey key,
@@ -341,7 +342,8 @@
     if (sub == null) {
       return null;
     }
-    return new RevocationReason(sub.isCritical(), sub.getData());
+    return new RevocationReason(sub.isCritical(), sub.isLongLength(),
+        sub.getData());
   }
 
   private static String reasonToString(RevocationReason reason) {
@@ -405,7 +407,6 @@
       // Don't check the timestamp of these certifications. This allows admins
       // to correct untrusted keys by signing them with a trusted key, such that
       // older signatures created by those keys retroactively appear valid.
-      @SuppressWarnings("unchecked")
       Iterator<PGPSignature> sigs = key.getSignaturesForID(userId);
 
       while (sigs.hasNext()) {
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
index 49657c6..ddee18d 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -19,7 +19,6 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Predicate;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.BaseEncoding;
@@ -211,14 +210,8 @@
   @VisibleForTesting
   public static FluentIterable<AccountExternalId> getGpgExtIds(ReviewDb db,
       Account.Id accountId) throws OrmException {
-    return FluentIterable
-        .from(db.accountExternalIds().byAccount(accountId))
-        .filter(new Predicate<AccountExternalId>() {
-          @Override
-          public boolean apply(AccountExternalId in) {
-            return in.isScheme(SCHEME_GPGKEY);
-          }
-        });
+    return FluentIterable.from(db.accountExternalIds().byAccount(accountId))
+        .filter(in -> in.isScheme(SCHEME_GPGKEY));
   }
 
   private Iterable<AccountExternalId> getGpgExtIds(AccountResource rsrc)
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 2deae3f..d2ccb88 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -18,7 +18,6 @@
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -49,7 +48,7 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
-import com.google.gerrit.server.mail.AddKeySender;
+import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -161,13 +160,8 @@
       if (!newExtIds.isEmpty()) {
         db.get().accountExternalIds().insert(newExtIds);
       }
-      db.get().accountExternalIds().deleteKeys(Iterables.transform(toRemove,
-          new Function<Fingerprint, AccountExternalId.Key>() {
-            @Override
-            public AccountExternalId.Key apply(Fingerprint fp) {
-              return toExtIdKey(fp.get());
-            }
-          }));
+      db.get().accountExternalIds().deleteKeys(
+          Iterables.transform(toRemove, fp -> toExtIdKey(fp.get())));
       accountCache.evict(rsrc.getUser().getAccountId());
       return toJson(newKeys, toRemove, store, rsrc.getUser());
     }
diff --git a/gerrit-gwtdebug/src/main/java/com/google/gwt/dev/codeserver/WebServer.java b/gerrit-gwtdebug/src/main/java/com/google/gwt/dev/codeserver/WebServer.java
deleted file mode 100644
index 728f276..0000000
--- a/gerrit-gwtdebug/src/main/java/com/google/gwt/dev/codeserver/WebServer.java
+++ /dev/null
@@ -1,541 +0,0 @@
-/*
- * Copyright 2011 Google Inc.
- *
- * 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.gwt.dev.codeserver;
-
-import com.google.gwt.core.ext.TreeLogger;
-import com.google.gwt.core.ext.TreeLogger.Type;
-import com.google.gwt.core.ext.UnableToCompleteException;
-import com.google.gwt.dev.codeserver.CompileDir.PolicyFile;
-import com.google.gwt.dev.codeserver.Pages.ErrorPage;
-import com.google.gwt.dev.json.JsonObject;
-
-import org.eclipse.jetty.http.MimeTypes;
-import org.eclipse.jetty.server.HttpConnection;
-import org.eclipse.jetty.server.Request;
-import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.servlet.ServletContextHandler;
-import org.eclipse.jetty.servlet.ServletHolder;
-import org.eclipse.jetty.servlets.GzipFilter;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.Date;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import javax.servlet.DispatcherType;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-/**
- * The web server for Super Dev Mode, also known as the code server. The URLs handled include:
- * <ul>
- *   <li>HTML pages for the front page and module pages</li>
- *   <li>JavaScript that implementing the bookmarklets</li>
- *   <li>The web API for recompiling a GWT app</li>
- *   <li>The output files and log files from the GWT compiler</li>
- *   <li>Java source code (for source-level debugging)</li>
- * </ul>
- *
- * <p>EXPERIMENTAL. There is no authentication, encryption, or XSS protection, so this server is
- * only safe to run on localhost.</p>
- */
-// This file was copied from GWT project and adjusted to run against
-// Jetty 9.2.2. The original diff can be found here:
-// https://gwt-review.googlesource.com/#/c/7857/13/dev/codeserver/java/com/google/gwt/dev/codeserver/WebServer.java
-public class WebServer {
-
-  private static final Pattern SAFE_DIRECTORY =
-      Pattern.compile("([a-zA-Z0-9_-]+\\.)*[a-zA-Z0-9_-]+"); // no extension needed
-
-  private static final Pattern SAFE_FILENAME =
-      Pattern.compile("([a-zA-Z0-9_-]+\\.)+[a-zA-Z0-9_-]+"); // an extension is required
-
-  private static final Pattern SAFE_MODULE_PATH =
-      Pattern.compile("/(" + SAFE_DIRECTORY + ")/$");
-
-  static final Pattern SAFE_DIRECTORY_PATH =
-      Pattern.compile("/(" + SAFE_DIRECTORY + "/)+$");
-
-  /* visible for testing */
-  static final Pattern SAFE_FILE_PATH =
-      Pattern.compile("/(" + SAFE_DIRECTORY + "/)+" + SAFE_FILENAME + "$");
-
-  static final Pattern STRONG_NAME = Pattern.compile("[\\dA-F]{32}");
-
-  private static final Pattern CACHE_JS_FILE = Pattern.compile("/(" + STRONG_NAME + ").cache.js$");
-
-  private static final MimeTypes MIME_TYPES = new MimeTypes();
-
-  private static final String TIME_IN_THE_PAST = "Mon, 01 Jan 1990 00:00:00 GMT";
-
-  private final SourceHandler handler;
-  private final JsonExporter jsonExporter;
-  private final OutboxTable outboxes;
-  private final JobRunner runner;
-  private final JobEventTable eventTable;
-
-  private final String bindAddress;
-  private final int port;
-
-  private Server server;
-
-  WebServer(SourceHandler handler, JsonExporter jsonExporter, OutboxTable outboxes,
-      JobRunner runner, JobEventTable eventTable, String bindAddress, int port) {
-    this.handler = handler;
-    this.jsonExporter = jsonExporter;
-    this.outboxes = outboxes;
-    this.runner = runner;
-    this.eventTable = eventTable;
-    this.bindAddress = bindAddress;
-    this.port = port;
-  }
-
-  @SuppressWarnings("serial")
-  void start(final TreeLogger logger) throws UnableToCompleteException {
-
-    Server newServer = new Server();
-    ServerConnector connector = new ServerConnector(newServer);
-    connector.setHost(bindAddress);
-    connector.setPort(port);
-    connector.setReuseAddress(false);
-    connector.setSoLingerTime(0);
-
-    newServer.addConnector(connector);
-
-    ServletContextHandler newHandler = new ServletContextHandler(ServletContextHandler.SESSIONS);
-    newHandler.setContextPath("/");
-    newHandler.addServlet(new ServletHolder(new HttpServlet() {
-      @Override
-      protected void doGet(HttpServletRequest request, HttpServletResponse response)
-          throws ServletException, IOException {
-        handleRequest(request.getPathInfo(), request, response, logger);
-      }
-    }), "/*");
-    newHandler.addFilter(GzipFilter.class, "/*", EnumSet.allOf(DispatcherType.class));
-    newServer.setHandler(newHandler);
-    try {
-      newServer.start();
-    } catch (Exception e) {
-      logger.log(TreeLogger.ERROR, "cannot start web server", e);
-      throw new UnableToCompleteException();
-    }
-    this.server = newServer;
-  }
-
-  public int getPort() {
-    return port;
-  }
-
-  public void stop() throws Exception {
-    server.stop();
-    server = null;
-  }
-
-  /**
-   * Returns the location of the compiler output. (Changes after every recompile.)
-   * @param outputModuleName the module name that the GWT compiler used in its output.
-   */
-  public File getCurrentWarDir(String outputModuleName) {
-    return outboxes.findByOutputModuleName(outputModuleName).getWarDir();
-  }
-
-  private void handleRequest(String target, HttpServletRequest request,
-      HttpServletResponse response, TreeLogger parentLogger)
-      throws IOException {
-
-    if (request.getMethod().equalsIgnoreCase("get")) {
-
-      TreeLogger logger = parentLogger.branch(Type.TRACE, "GET " + target);
-
-      Response page = doGet(target, request, logger);
-      if (page == null) {
-        logger.log(Type.WARN, "not handled: " + target);
-        return;
-      }
-
-      setHandled(request);
-      if (!target.endsWith(".cache.js")) {
-        // Make sure IE9 doesn't cache any pages.
-        // (Nearly all pages may change on server restart.)
-        response.setHeader("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate");
-        response.setHeader("Pragma", "no-cache");
-        response.setHeader("Expires", TIME_IN_THE_PAST);
-        response.setDateHeader("Date", new Date().getTime());
-      }
-      page.send(request, response, logger);
-    }
-  }
-
-  /**
-   * Returns the page that should be sent in response to a GET request, or null for no response.
-   */
-  private Response doGet(String target, HttpServletRequest request, TreeLogger logger)
-      throws IOException {
-
-    if (target.equals("/")) {
-      JsonObject json = jsonExporter.exportFrontPageVars();
-      return Pages.newHtmlPage("config", json, "frontpage.html");
-    }
-
-    if (target.equals("/dev_mode_on.js")) {
-      JsonObject json = jsonExporter.exportDevModeOnVars();
-      return Responses.newJavascriptResponse("__gwt_codeserver_config", json,
-          "dev_mode_on.js");
-    }
-
-    // Recompile on request from the bookmarklet.
-    // This is a GET because a bookmarklet can call it from a different origin (JSONP).
-    if (target.startsWith("/recompile/")) {
-      String moduleName = target.substring("/recompile/".length());
-      Outbox box = outboxes.findByOutputModuleName(moduleName);
-      if (box == null) {
-        return new ErrorPage("No such module: " + moduleName);
-      }
-
-      // We are passing properties from an unauthenticated GET request directly to the compiler.
-      // This should be safe, but only because these are binding properties. For each binding
-      // property, you can only choose from a set of predefined values. So all an attacker can do is
-      // cause a spurious recompile, resulting in an unexpected permutation being loaded later.
-      //
-      // It would be unsafe to allow a configuration property to be changed.
-      Job job = box.makeJob(getBindingProperties(request), logger);
-      runner.submit(job);
-      Job.Result result = job.waitForResult();
-      JsonObject json = jsonExporter.exportRecompileResponse(result);
-      return Responses.newJsonResponse(json);
-    }
-
-    if (target.startsWith("/log/")) {
-      String moduleName = target.substring("/log/".length());
-      Outbox box = outboxes.findByOutputModuleName(moduleName);
-      if (box == null) {
-        return new ErrorPage("No such module: " + moduleName);
-      } else if (box.containsStubCompile()) {
-        return new ErrorPage("This module hasn't been compiled yet.");
-      } else {
-        return makeLogPage(box);
-      }
-    }
-
-    if (target.equals("/favicon.ico")) {
-      InputStream faviconStream = getClass().getResourceAsStream("favicon.ico");
-      if (faviconStream == null) {
-        return new ErrorPage("icon not found");
-      }
-      // IE8 will not load the favicon in an img tag with the default MIME type,
-      // so use "image/x-icon" instead.
-      return Responses.newBinaryStreamResponse("image/x-icon", faviconStream);
-    }
-
-    if (target.equals("/policies/")) {
-      return makePolicyIndexPage();
-    }
-
-    if (target.equals("/progress")) {
-      // TODO: return a list of progress objects here, one for each job.
-      JobEvent event = eventTable.getCompilingJobEvent();
-
-      JsonObject json;
-      if (event == null) {
-        json = new JsonObject();
-        json.put("status", "idle");
-      } else {
-        json = jsonExporter.exportProgressResponse(event);
-      }
-      return Responses.newJsonResponse(json);
-    }
-
-    Matcher matcher = SAFE_MODULE_PATH.matcher(target);
-    if (matcher.matches()) {
-      return makeModulePage(matcher.group(1));
-    }
-
-    matcher = SAFE_DIRECTORY_PATH.matcher(target);
-    if (matcher.matches() && SourceHandler.isSourceMapRequest(target)) {
-      return handler.handle(target, request, logger);
-    }
-
-    matcher = SAFE_FILE_PATH.matcher(target);
-    if (matcher.matches()) {
-      if (SourceHandler.isSourceMapRequest(target)) {
-        return handler.handle(target, request, logger);
-      }
-      if (target.startsWith("/policies/")) {
-        return makePolicyFilePage(target);
-      }
-      return makeCompilerOutputPage(target);
-    }
-
-    logger.log(TreeLogger.WARN, "ignored get request: " + target);
-    return null; // not handled
-  }
-
-  /**
-   * Returns a file that the compiler wrote to its war directory.
-   */
-  private Response makeCompilerOutputPage(String target) {
-
-    int secondSlash = target.indexOf('/', 1);
-    String moduleName = target.substring(1, secondSlash);
-    Outbox box = outboxes.findByOutputModuleName(moduleName);
-    if (box == null) {
-      return new ErrorPage("No such module: " + moduleName);
-    }
-
-    final String contentEncoding;
-    File file = box.getOutputFile(target);
-    if (!file.isFile()) {
-      // perhaps it's compressed
-      file = box.getOutputFile(target + ".gz");
-      if (!file.isFile()) {
-        return new ErrorPage("not found: " + file.toString());
-      }
-      contentEncoding = "gzip";
-    } else {
-      contentEncoding = null;
-    }
-
-    final String sourceMapUrl;
-    Matcher match = CACHE_JS_FILE.matcher(target);
-    if (match.matches()) {
-      String strongName = match.group(1);
-      String template = SourceHandler.sourceMapLocationTemplate(moduleName);
-      sourceMapUrl = template.replace("__HASH__", strongName);
-    } else {
-      sourceMapUrl = null;
-    }
-
-    String mimeType = guessMimeType(target);
-    final Response barePage = Responses.newFileResponse(mimeType, file);
-
-    // Wrap the response to send the extra headers.
-    return new Response() {
-      @Override
-      public void send(HttpServletRequest request, HttpServletResponse response, TreeLogger logger)
-          throws IOException {
-        // TODO: why do we need this? Looks like Ray added it a long time ago.
-        response.setHeader("Access-Control-Allow-Origin", "*");
-
-        if (sourceMapUrl != null) {
-          response.setHeader("X-SourceMap", sourceMapUrl);
-          response.setHeader("SourceMap", sourceMapUrl);
-        }
-
-        if (contentEncoding != null) {
-          if (!request.getHeader("Accept-Encoding").contains("gzip")) {
-            response.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED);
-            logger.log(TreeLogger.WARN, "client doesn't accept gzip; bailing");
-            return;
-          }
-          response.setHeader("Content-Encoding", "gzip");
-        }
-
-        barePage.send(request, response, logger);
-      }
-    };
-  }
-
-  private Response makeModulePage(String moduleName) {
-    Outbox box = outboxes.findByOutputModuleName(moduleName);
-    if (box == null) {
-      return new ErrorPage("No such module: " + moduleName);
-    }
-
-    JsonObject json = jsonExporter.exportModulePageVars(box);
-    return Pages.newHtmlPage("config", json, "modulepage.html");
-  }
-
-  private Response makePolicyIndexPage() {
-
-    return new Response() {
-
-      @Override
-      public void send(HttpServletRequest request, HttpServletResponse response, TreeLogger logger)
-          throws IOException {
-        response.setContentType("text/html");
-
-        HtmlWriter out = new HtmlWriter(response.getWriter());
-
-        out.startTag("html").nl();
-        out.startTag("head").nl();
-        out.startTag("title").text("Policy Files").endTag("title").nl();
-        out.endTag("head");
-        out.startTag("body");
-
-        out.startTag("h1").text("Policy Files").endTag("h1").nl();
-
-        for (Outbox box : outboxes.getOutboxes()) {
-          List<PolicyFile> policies = box.readRpcPolicyManifest();
-          if (!policies.isEmpty()) {
-            out.startTag("h2").text(box.getOutputModuleName()).endTag("h2").nl();
-
-            out.startTag("table").nl();
-            for (PolicyFile policy : policies) {
-
-              out.startTag("tr");
-
-              out.startTag("td");
-
-              out.startTag("a", "href=", policy.getServiceSourceUrl());
-              out.text(policy.getServiceName());
-              out.endTag("a");
-
-              out.endTag("td");
-
-              out.startTag("td");
-
-              out.startTag("a", "href=", policy.getUrl());
-              out.text(policy.getName());
-              out.endTag("a");
-
-              out.endTag("td");
-
-              out.endTag("tr").nl();
-            }
-            out.endTag("table").nl();
-          }
-        }
-
-        out.endTag("body").nl();
-        out.endTag("html").nl();
-      }
-    };
-  }
-
-  private Response makePolicyFilePage(String target) {
-
-    int secondSlash = target.indexOf('/', 1);
-    if (secondSlash < 1) {
-      return new ErrorPage("invalid URL for policy file: " + target);
-    }
-
-    String rest = target.substring(secondSlash + 1);
-    if (rest.contains("/") || !rest.endsWith(".gwt.rpc")) {
-      return new ErrorPage("invalid name for policy file: " + rest);
-    }
-
-    File fileToSend = outboxes.findPolicyFile(rest);
-    if (fileToSend == null) {
-      return new ErrorPage("Policy file not found: " + rest);
-    }
-
-    return Responses.newFileResponse("text/plain", fileToSend);
-  }
-
-  /**
-   * Sends the log file as html with errors highlighted in red.
-   */
-  private Response makeLogPage(final Outbox box) {
-    final File file = box.getCompileLog();
-    if (!file.isFile()) {
-      return new ErrorPage("log file not found");
-    }
-
-    return new Response() {
-
-      @Override
-      public void send(HttpServletRequest request, HttpServletResponse response, TreeLogger logger)
-          throws IOException {
-        BufferedReader reader = new BufferedReader(new FileReader(file));
-
-        response.setStatus(HttpServletResponse.SC_OK);
-        response.setContentType("text/html");
-        response.setHeader("Content-Style-Type", "text/css");
-
-        HtmlWriter out = new HtmlWriter(response.getWriter());
-        out.startTag("html").nl();
-        out.startTag("head").nl();
-        out.startTag("title").text(box.getOutputModuleName() + " compile log").endTag("title").nl();
-        out.startTag("style").nl();
-        out.text(".error { color: red; font-weight: bold; }").nl();
-        out.endTag("style").nl();
-        out.endTag("head").nl();
-        out.startTag("body").nl();
-        sendLogAsHtml(reader, out);
-        out.endTag("body").nl();
-        out.endTag("html").nl();
-      }
-    };
-  }
-
-  private static final Pattern ERROR_PATTERN = Pattern.compile("\\[ERROR\\]");
-
-  /**
-   * Copies in to out line by line, escaping each line for html characters and highlighting
-   * error lines. Closes <code>in</code> when done.
-   */
-  private static void sendLogAsHtml(BufferedReader in, HtmlWriter out) throws IOException {
-    try {
-      out.startTag("pre").nl();
-      String line = in.readLine();
-      while (line != null) {
-        Matcher m = ERROR_PATTERN.matcher(line);
-        boolean error = m.find();
-        if (error) {
-          out.startTag("span", "class=", "error");
-        }
-        out.text(line);
-        if (error) {
-          out.endTag("span");
-        }
-        out.nl(); // the readLine doesn't include the newline.
-        line = in.readLine();
-      }
-      out.endTag("pre").nl();
-    } finally {
-      in.close();
-    }
-  }
-
-  /* visible for testing */
-  static String guessMimeType(String filename) {
-    String mimeType = MIME_TYPES.getMimeByExtension(filename);
-    return mimeType != null ? mimeType : "";
-  }
-
-  /**
-   * Returns the binding properties from the web page where dev mode is being used. (As passed in
-   * by dev_mode_on.js in a JSONP request to "/recompile".)
-   */
-  private Map<String, String> getBindingProperties(HttpServletRequest request) {
-    Map<String, String> result = new HashMap<>();
-    for (Object key : request.getParameterMap().keySet()) {
-      String propName = (String) key;
-      if (!propName.equals("_callback")) {
-        result.put(propName, request.getParameter(propName));
-      }
-    }
-    return result;
-  }
-
-  private static void setHandled(HttpServletRequest request) {
-    Request baseRequest = (request instanceof Request) ? (Request) request :
-        HttpConnection.getCurrentConnection().getHttpChannel().getRequest();
-    baseRequest.setHandled(true);
-  }
-}
diff --git a/gerrit-gwtexpui/BUCK b/gerrit-gwtexpui/BUCK
index 79a97a9..23db13f 100644
--- a/gerrit-gwtexpui/BUCK
+++ b/gerrit-gwtexpui/BUCK
@@ -80,7 +80,6 @@
     '//lib/gwt:user',
     '//lib/gwt:dev',
   ],
-  source_under_test = [':SafeHtml'],
 )
 
 gwt_module(
diff --git a/gerrit-gwtexpui/BUILD b/gerrit-gwtexpui/BUILD
index d3b03ef..d74fc8b 100644
--- a/gerrit-gwtexpui/BUILD
+++ b/gerrit-gwtexpui/BUILD
@@ -19,6 +19,10 @@
     '//lib/gwt:user',
   ],
   visibility = ['//visibility:public'],
+  data = [
+    '//lib:LICENSE-clippy',
+    '//lib:LICENSE-silk_icons',
+  ],
 )
 
 java_library(
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
index cf5a445..525a837 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
@@ -43,7 +43,7 @@
   }
 
   @Override
-  public final void requestSuggestions(final Request request, final Callback cb) {
+  public final void requestSuggestions(Request request, Callback cb) {
     onRequestSuggestions(request, new Callback() {
       @Override
       public void onSuggestionsReady(final Request request,
@@ -88,27 +88,28 @@
         ds = escape(ds);
       }
 
-      StringBuilder pattern = new StringBuilder();
-      for (String qterm : splitQuery(qstr)) {
-        qterm = escape(qterm);
-        // We now surround qstr by <strong>. But the chosen approach is not too
-        // smooth, if qstr is small (e.g.: "t") and this small qstr may occur in
-        // escapes (e.g.: "Tim &lt;email@example.org&gt;"). Those escapes will
-        // get <strong>-ed as well (e.g.: "&lt;" -> "&<strong>l</strong>t;"). But
-        // as repairing those mangled escapes is easier than not mangling them in
-        // the first place, we repair them afterwards.
-
-        if (pattern.length() > 0) {
-          pattern.append("|");
+      if (qstr != null && !qstr.isEmpty()) {
+        StringBuilder pattern = new StringBuilder();
+        for (String qterm : splitQuery(qstr)) {
+          qterm = escape(qterm);
+          // We now surround qstr by <strong>. But the chosen approach is not too
+          // smooth, if qstr is small (e.g.: "t") and this small qstr may occur in
+          // escapes (e.g.: "Tim &lt;email@example.org&gt;"). Those escapes will
+          // get <strong>-ed as well (e.g.: "&lt;" -> "&<strong>l</strong>t;"). But
+          // as repairing those mangled escapes is easier than not mangling them in
+          // the first place, we repair them afterwards.
+          if (pattern.length() > 0) {
+            pattern.append("|");
+          }
+          pattern.append(qterm);
         }
-        pattern.append(qterm);
+
+        ds = sgi(ds, "(" + pattern.toString() + ")", "<strong>$1</strong>");
+
+        // Repairing <strong>-ed escapes.
+        ds = sgi(ds, "(&[a-z]*)<strong>([a-z]*)</strong>([a-z]*;)", "$1$2$3");
       }
 
-      ds = sgi(ds, "(" + pattern.toString() + ")", "<strong>$1</strong>");
-
-      // Repairing <strong>-ed escapes.
-      ds = sgi(ds, "(&[a-z]*)<strong>([a-z]*)</strong>([a-z]*;)", "$1$2$3");
-
       displayString = ds;
     }
 
diff --git a/gerrit-gwtui-common/BUCK b/gerrit-gwtui-common/BUCK
index ef78d98..729b7e7 100644
--- a/gerrit-gwtui-common/BUCK
+++ b/gerrit-gwtui-common/BUCK
@@ -8,7 +8,6 @@
 ]
 DEPS = ['//lib/gwt:user']
 SRC = 'src/main/java/com/google/gerrit/'
-DIFFY = glob(['src/main/resources/com/google/gerrit/client/diffy*.png'])
 
 gwt_module(
   name = 'client',
@@ -36,9 +35,9 @@
   visibility = ['PUBLIC'],
 )
 
-prebuilt_jar(
+java_library(
   name = 'diffy_logo',
-  binary_jar = ':diffy_image_files_ln',
+  resources = glob(['src/main/resources/com/google/gerrit/client/diffy*.png']),
   deps = [
     '//lib:LICENSE-diffy',
     '//lib:LICENSE-CC-BY3.0-unported',
@@ -46,17 +45,6 @@
   visibility = ['PUBLIC'],
 )
 
-genrule(
-  name = 'diffy_image_files_ln',
-  cmd = 'ln -s $(location :diffy_image_files) $OUT',
-  out = 'diffy_images.jar',
-)
-
-java_library(
-  name = 'diffy_image_files',
-  resources = DIFFY,
-)
-
 java_test(
   name = 'client_tests',
   srcs = glob(['src/test/java/**/*.java']),
@@ -66,7 +54,6 @@
     '//lib/gwt:user',
     '//lib/jgit/org.eclipse.jgit:jgit',
   ],
-  source_under_test = [':client'],
   vm_args = ['-Xmx512m'],
   visibility = ['//tools/eclipse:classpath'],
 )
diff --git a/gerrit-gwtui-common/BUILD b/gerrit-gwtui-common/BUILD
new file mode 100644
index 0000000..c6ad882
--- /dev/null
+++ b/gerrit-gwtui-common/BUILD
@@ -0,0 +1,59 @@
+load('//tools/bzl:java.bzl', 'java_library2')
+load('//tools/bzl:junit.bzl', 'junit_tests')
+load('//tools/bzl:gwt.bzl', 'gwt_module')
+
+EXPORTED_DEPS = [
+  '//gerrit-common:client',
+  '//gerrit-gwtexpui:Clippy',
+  '//gerrit-gwtexpui:GlobalKey',
+  '//gerrit-gwtexpui:Progress',
+  '//gerrit-gwtexpui:SafeHtml',
+  '//gerrit-gwtexpui:UserAgent',
+]
+DEPS = ['//lib/gwt:user']
+SRC = 'src/main/java/com/google/gerrit/'
+
+gwt_module(
+  name = 'client',
+  srcs = glob(['src/main/**/*.java']),
+  gwt_xml = SRC + 'GerritGwtUICommon.gwt.xml',
+  resources = glob(
+      ['src/main/**/*'],
+      exclude = [SRC + 'client/**/*.java'] +
+      [SRC + 'GerritGwtUICommon.gwt.xml']
+  ),
+  exported_deps = EXPORTED_DEPS,
+  deps = DEPS,
+  visibility = ['//visibility:public'],
+)
+
+java_library2(
+  name = 'client-lib',
+  srcs = glob(['src/main/**/*.java']),
+  resources = glob(['src/main/**/*']),
+  exported_deps = EXPORTED_DEPS,
+  deps = DEPS,
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'diffy_logo',
+  resources = glob(['src/main/resources/com/google/gerrit/client/diffy*.png']),
+  data = [
+    '//lib:LICENSE-diffy',
+    '//lib:LICENSE-CC-BY3.0-unported',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+junit_tests(
+  name = 'client_tests',
+  srcs = glob(['src/test/java/**/*.java']),
+  deps = [
+    ':client',
+    '//lib:junit',
+    '//lib/gwt:dev',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java
index 0a339a1..eb10718 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java
@@ -22,6 +22,7 @@
   CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
   CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK,
   CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK,
+  CHANGE_SCREEN_HISTORY_RIGHT_OF_BUTTONS,
 
   /* MyPasswordScreen */
   PASSWORD_SCREEN_BOTTOM,
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java
index 95751fa..c8e23e5 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java
@@ -30,6 +30,9 @@
   @Source("user_add.png")
   ImageResource addUser();
 
+  @Source("user_edit.png")
+  ImageResource editUser();
+
   // derived from resultset_next.png
   @Source("resultset_down_gray.png")
   ImageResource arrowDown();
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AgreementInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AgreementInfo.java
new file mode 100644
index 0000000..5fb2f48
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AgreementInfo.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2016 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.client.info;
+
+import com.google.gwt.core.client.JavaScriptObject;
+
+public class AgreementInfo extends JavaScriptObject {
+  public final native String name() /*-{ return this.name; }-*/;
+  public final native String description() /*-{ return this.description; }-*/;
+  public final native String url() /*-{ return this.url; }-*/;
+  public final native GroupInfo autoVerifyGroup() /*-{ return this.auto_verify_group; }-*/;
+
+  protected AgreementInfo() {
+  }
+}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
index 0e3c32b..ca3912c 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.client.info;
 
 import com.google.gerrit.client.rpc.Natives;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
-import com.google.gerrit.reviewdb.client.AuthType;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
 
 import java.util.ArrayList;
@@ -52,34 +53,46 @@
     return authType() == AuthType.CUSTOM_EXTENSION;
   }
 
-  public final boolean canEdit(Account.FieldName f) {
+  public final boolean canEdit(AccountFieldName f) {
     return editableAccountFields().contains(f);
   }
 
-  public final List<Account.FieldName> editableAccountFields() {
-    List<Account.FieldName> fields = new ArrayList<>();
+  public final List<AccountFieldName> editableAccountFields() {
+    List<AccountFieldName> fields = new ArrayList<>();
     for (String f : Natives.asList(_editableAccountFields())) {
-      fields.add(Account.FieldName.valueOf(f));
+      fields.add(AccountFieldName.valueOf(f));
     }
     return fields;
   }
 
+  public final List<AgreementInfo> contributorAgreements() {
+    List<AgreementInfo> agreements = new ArrayList<>();
+    for (AgreementInfo a : Natives.asList(_contributorAgreements())) {
+      agreements.add(a);
+    }
+    return agreements;
+  }
+
   public final boolean siteHasUsernames() {
     if (isCustomExtension()
         && httpPasswordUrl() != null
-        && !canEdit(FieldName.USER_NAME)) {
+        && !canEdit(AccountFieldName.USER_NAME)) {
       return false;
     }
     return true;
   }
 
   public final boolean isHttpPasswordSettingsEnabled() {
-    if (isLdap() && isGitBasicAuth()) {
+    if (isGitBasicAuth() && gitBasicAuthPolicy() == GitBasicAuthPolicy.LDAP) {
       return false;
     }
     return true;
   }
 
+  public final GitBasicAuthPolicy gitBasicAuthPolicy() {
+    return GitBasicAuthPolicy.valueOf(gitBasicAuthPolicyRaw());
+  }
+
   public final native boolean useContributorAgreements()
   /*-{ return this.use_contributor_agreements || false; }-*/;
   public final native String loginUrl() /*-{ return this.login_url; }-*/;
@@ -90,9 +103,13 @@
   public final native String editFullNameUrl() /*-{ return this.edit_full_name_url; }-*/;
   public final native String httpPasswordUrl() /*-{ return this.http_password_url; }-*/;
   public final native boolean isGitBasicAuth() /*-{ return this.is_git_basic_auth || false; }-*/;
+  private native String gitBasicAuthPolicyRaw()
+  /*-{ return this.git_basic_auth_policy; }-*/;
   private native String authTypeRaw() /*-{ return this.auth_type; }-*/;
   private native JsArrayString _editableAccountFields()
   /*-{ return this.editable_account_fields; }-*/;
+  private native JsArray<AgreementInfo> _contributorAgreements()
+  /*-{ return this.contributor_agreements; }-*/;
 
   protected AuthInfo() {
   }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
index 9eea93e..ff060b8 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
@@ -115,6 +115,7 @@
   private native String statusRaw() /*-{ return this.status; }-*/;
   public final native String subject() /*-{ return this.subject; }-*/;
   public final native AccountInfo owner() /*-{ return this.owner; }-*/;
+  public final native AccountInfo assignee() /*-{ return this.assignee; }-*/;
   private native String createdRaw() /*-{ return this.created; }-*/;
   private native String updatedRaw() /*-{ return this.updated; }-*/;
   private native String submittedRaw() /*-{ return this.submitted; }-*/;
@@ -414,6 +415,10 @@
       return PatchSet.Id.toId(_number());
     }
 
+    public final boolean isMerge() {
+      return commit().parents().length() > 1;
+    }
+
     protected RevisionInfo () {
     }
   }
@@ -458,6 +463,7 @@
     public final native AccountInfo author() /*-{ return this.author; }-*/;
     public final native String message() /*-{ return this.message; }-*/;
     public final native int _revisionNumber() /*-{ return this._revision_number || 0; }-*/;
+    public final native String tag() /*-{ return this.tag; }-*/;
     private native String dateRaw() /*-{ return this.date; }-*/;
 
     public final Timestamp date() {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
index 9b290a5..e557470 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client.info;
 
 import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.common.data.FilenameComparator;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
@@ -30,7 +31,6 @@
   public final native boolean binary() /*-{ return this.binary || false; }-*/;
   public final native String status() /*-{ return this.status; }-*/;
 
-
   // JSNI methods cannot have 'long' as a parameter type or a return type and
   // it's suggested to use double in this case:
   // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html#important
@@ -48,37 +48,20 @@
   public final native void _row(int r) /*-{ this._row = r }-*/;
 
   public static void sortFileInfoByPath(JsArray<FileInfo> list) {
-    Collections.sort(Natives.asList(list), new Comparator<FileInfo>() {
-      @Override
-      public int compare(FileInfo a, FileInfo b) {
-        if (Patch.COMMIT_MSG.equals(a.path())) {
-          return -1;
-        } else if (Patch.COMMIT_MSG.equals(b.path())) {
-          return 1;
-        }
-        // Look at file suffixes to check if it makes sense to use a different order
-        int s1 = a.path().lastIndexOf('.');
-        int s2 = b.path().lastIndexOf('.');
-        if (s1 > 0 && s2 > 0 &&
-            a.path().substring(0, s1).equals(b.path().substring(0, s2))) {
-            String suffixA = a.path().substring(s1);
-            String suffixB = b.path().substring(s2);
-            // C++ and C: give priority to header files (.h/.hpp/...)
-            if (suffixA.indexOf(".h") == 0) {
-                return -1;
-            } else if (suffixB.indexOf(".h") == 0) {
-                return 1;
-            }
-        }
-        return a.path().compareTo(b.path());
-      }
-    });
+    Collections.sort(Natives.asList(list),
+        Comparator.comparing(FileInfo::path, FilenameComparator.INSTANCE));
   }
 
   public static String getFileName(String path) {
-    String fileName = Patch.COMMIT_MSG.equals(path)
-        ? "Commit Message"
-        : path;
+    String fileName;
+    if (Patch.COMMIT_MSG.equals(path)) {
+      fileName = "Commit Message";
+    } else if (Patch.MERGE_LIST.equals(path)) {
+      fileName = "Merge List";
+    } else {
+      fileName = path;
+    }
+
     int s = fileName.lastIndexOf('/');
     return s >= 0 ? fileName.substring(s + 1) : fileName;
   }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
index 45953cb..9c751ed0 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
@@ -47,6 +48,7 @@
     p.downloadCommand(d.downloadCommand);
     p.dateFormat(d.getDateFormat());
     p.timeFormat(d.getTimeFormat());
+    p.highlightAssigneeInChangeTable(d.highlightAssigneeInChangeTable);
     p.relativeDateInChangeTable(d.relativeDateInChangeTable);
     p.sizeBarInChangeTable(d.sizeBarInChangeTable);
     p.legacycidInChangeTable(d.legacycidInChangeTable);
@@ -55,6 +57,7 @@
     p.reviewCategoryStrategy(d.getReviewCategoryStrategy());
     p.diffView(d.getDiffView());
     p.emailStrategy(d.emailStrategy);
+    p.defaultBaseForMerges(d.defaultBaseForMerges);
     return p;
   }
 
@@ -98,6 +101,9 @@
   private native String timeFormatRaw()
   /*-{ return this.time_format }-*/;
 
+  public final native boolean highlightAssigneeInChangeTable()
+  /*-{ return this.highlight_assignee_in_change_table || false }-*/;
+
   public final native boolean relativeDateInChangeTable()
   /*-{ return this.relative_date_in_change_table || false }-*/;
 
@@ -135,6 +141,14 @@
   private native String emailStrategyRaw()
   /*-{ return this.email_strategy }-*/;
 
+  public final DefaultBase defaultBaseForMerges() {
+    String s = defaultBaseForMergesRaw();
+    return s != null ? DefaultBase.valueOf(s) : null;
+  }
+
+  private native String defaultBaseForMergesRaw()
+  /*-{ return this.default_base_for_merges }-*/;
+
   public final native JsArray<TopMenuItem> my()
   /*-{ return this.my; }-*/;
 
@@ -168,6 +182,9 @@
   private native void timeFormatRaw(String f)
   /*-{ this.time_format = f }-*/;
 
+  public final native void highlightAssigneeInChangeTable(boolean d)
+  /*-{ this.highlight_assignee_in_change_table = d }-*/;
+
   public final native void relativeDateInChangeTable(boolean d)
   /*-{ this.relative_date_in_change_table = d }-*/;
 
@@ -201,6 +218,12 @@
   private native void emailStrategyRaw(String s)
   /*-{ this.email_strategy = s }-*/;
 
+  public final void defaultBaseForMerges(DefaultBase b) {
+    defaultBaseForMergesRaw(b != null ? b.toString() : null);
+  }
+  private native void defaultBaseForMergesRaw(String b)
+  /*-{ this.default_base_for_merges = b }-*/;
+
   public final void setMyMenus(List<TopMenuItem> myMenus) {
     initMy();
     for (TopMenuItem n : myMenus) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java
index 750412d..a111896 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java
@@ -14,8 +14,13 @@
 
 package com.google.gerrit.client.info;
 
+import com.google.gerrit.extensions.client.UiType;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArrayString;
+
+import java.util.ArrayList;
+import java.util.List;
 
 public class GerritInfo extends JavaScriptObject {
   public final Project.NameKey allProjectsNameKey() {
@@ -42,6 +47,19 @@
   public final native String reportBugUrl() /*-{ return this.report_bug_url; }-*/;
   public final native String reportBugText() /*-{ return this.report_bug_text; }-*/;
 
+  private native JsArrayString _webUis() /*-{ return this.web_uis; }-*/;
+  public final List<UiType> webUis() {
+    JsArrayString webUis = _webUis();
+    List<UiType> result = new ArrayList<>(webUis.length());
+    for (int i = 0; i < webUis.length(); i++) {
+      UiType t = UiType.parse(webUis.get(i));
+      if (t != null) {
+        result.add(t);
+      }
+    }
+    return result;
+  }
+
   protected GerritInfo() {
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupBaseInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupBaseInfo.java
similarity index 95%
rename from gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupBaseInfo.java
rename to gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupBaseInfo.java
index 4811e59..deed44d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupBaseInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupBaseInfo.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.client.groups;
+package com.google.gerrit.client.info;
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gwt.core.client.JavaScriptObject;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupInfo.java
similarity index 95%
rename from gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java
rename to gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupInfo.java
index c3fd4ed..fa051a1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupInfo.java
@@ -12,9 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.client.groups;
+package com.google.gerrit.client.info;
 
-import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
index 112c4db..be8d076 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
@@ -59,6 +59,7 @@
     public final native int largeChange() /*-{ return this.large_change || 0; }-*/;
     public final native String replyLabel() /*-{ return this.reply_label; }-*/;
     public final native String replyTooltip() /*-{ return this.reply_tooltip; }-*/;
+    public final native boolean showAssignee() /*-{ return this.show_assignee || false; }-*/;
     public final native int updateDelay() /*-{ return this.update_delay || 0; }-*/;
     public final native boolean isSubmitWholeTopicEnabled() /*-{
         return this.submit_whole_topic; }-*/;
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
index cf7e1d8..5a6918a 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.ui;
 
+import com.google.gwt.user.client.Timer;
 import com.google.gwt.user.client.ui.SuggestOracle;
 
 /**
@@ -31,6 +32,10 @@
   private final SuggestOracle oracle;
   private Query query;
   private String last;
+  private Timer requestRetentionTimer;
+  private boolean cancelOutstandingRequest;
+
+  private boolean serveSuggestions;
 
   public RemoteSuggestOracle(SuggestOracle src) {
     oracle = src;
@@ -42,13 +47,33 @@
 
   @Override
   public void requestSuggestions(Request req, Callback cb) {
-    Query q = new Query(req, cb);
-    if (query == null) {
-      query = q;
-      q.start();
-    } else {
-      query = q;
+    if (!serveSuggestions){
+      return;
     }
+
+    // Use a timer for key stroke retention, such that we don't query the
+    // backend for each and every keystroke we receive.
+    if (requestRetentionTimer != null) {
+      requestRetentionTimer.cancel();
+    }
+    requestRetentionTimer = new Timer() {
+      @Override
+      public void run() {
+        Query q = new Query(req, cb);
+        if (query == null) {
+          query = q;
+          q.start();
+        } else {
+          query = q;
+        }
+      }
+    };
+    requestRetentionTimer.schedule(200);
+  }
+
+  @Override
+  public void requestDefaultSuggestions(Request req, Callback cb) {
+    requestSuggestions(req, cb);
   }
 
   @Override
@@ -56,6 +81,19 @@
     return oracle.isDisplayStringHTML();
   }
 
+  public void cancelOutstandingRequest() {
+    if (requestRetentionTimer != null) {
+      requestRetentionTimer.cancel();
+    }
+    if (query != null) {
+      cancelOutstandingRequest = true;
+    }
+  }
+
+  public void setServeSuggestions(boolean serveSuggestions) {
+    this.serveSuggestions = serveSuggestions;
+  }
+
   private class Query implements Callback {
     final Request request;
     final Callback callback;
@@ -71,7 +109,11 @@
 
     @Override
     public void onSuggestionsReady(Request req, Response res) {
-      if (query == this) {
+      if (cancelOutstandingRequest || !serveSuggestions) {
+        // If cancelOutstandingRequest() was called, we ignore this response
+        cancelOutstandingRequest = false;
+        query = null;
+      } else if (query == this) {
         // No new request was started while this query was running.
         // Propose this request's response as the suggestions.
         query = null;
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_edit.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_edit.png
new file mode 100644
index 0000000..c1974cd
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_edit.png
Binary files differ
diff --git a/gerrit-gwtui/BUCK b/gerrit-gwtui/BUCK
index 1e39831..63d52b0 100644
--- a/gerrit-gwtui/BUCK
+++ b/gerrit-gwtui/BUCK
@@ -61,7 +61,6 @@
     '//lib/gwt:dev',
     '//lib/gwt:user',
   ],
-  source_under_test = [':ui_module'],
   vm_args = ['-Xmx512m'],
   visibility = ['//tools/eclipse:classpath'],
 )
diff --git a/gerrit-gwtui/BUILD b/gerrit-gwtui/BUILD
new file mode 100644
index 0000000..7e692e8
--- /dev/null
+++ b/gerrit-gwtui/BUILD
@@ -0,0 +1,16 @@
+load('//tools/bzl:gwt.bzl', 'gwt_genrule', 'gen_ui_module',
+     'gwt_user_agent_permutations')
+load('//tools/bzl:license.bzl', 'license_test')
+
+gwt_genrule()
+gwt_genrule('_r')
+
+gen_ui_module(name = 'ui_module')
+gen_ui_module(name = 'ui_module', suffix = '_r')
+
+gwt_user_agent_permutations()
+
+license_test(
+  name = "ui_module_license_test",
+  target = ":ui_module",
+)
diff --git a/gerrit-gwtui/gwt.defs b/gerrit-gwtui/gwt.defs
index cd8fa74..85553f2 100644
--- a/gerrit-gwtui/gwt.defs
+++ b/gerrit-gwtui/gwt.defs
@@ -18,14 +18,14 @@
   'firefox',
   'gecko1_8',
   'safari',
-  'msie', 'ie8', 'ie9', 'ie10', 'ie11',
+  'msie', 'ie8', 'ie9', 'ie10',
   'edge',
 ]
 ALIASES = {
   'chrome': 'safari',
   'firefox': 'gecko1_8',
-  'msie': 'ie11',
-  'edge': 'edge',
+  'msie': 'ie10',
+  'edge': 'gecko1_8',
 }
 MODULE = 'com.google.gerrit.GerritGwtUI'
 CPU_COUNT = cpu_count()
@@ -124,7 +124,6 @@
     prebuilt_jar(
       name = '%s_gwtxml_lib' % gwt_name,
       binary_jar = ':%s_gwtxml_gen' % gwt_name,
-      gwt_jar = ':%s_gwtxml_gen' % gwt_name,
     )
     gwt_binary(
       name = gwt_name,
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffObject.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffObject.java
new file mode 100644
index 0000000..0a1aadd
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffObject.java
@@ -0,0 +1,198 @@
+// Copyright (C) 2016 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.client;
+
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+
+/**
+ * Represent an object that can be diffed. This can be either a regular patch
+ * set, the base of a patch set, the parent of a merge, the auto-merge of a
+ * merge or an edit patch set.
+ */
+public class DiffObject {
+  public static final String AUTO_MERGE = "AutoMerge";
+
+  /**
+   * Parses a string that represents a diff object.
+   * <p>
+   * The following string representations are supported:
+   * <ul>
+   * <li>a positive integer: represents a patch set
+   * <li>a negative integer: represents a parent of a merge patch set
+   * <li>'0': represents the edit patch set
+   * <li>empty string or null: represents the parent of a 1-parent patch set,
+   * also called base
+   * <li>'AutoMerge': represents the auto-merge of a merge patch set
+   * </ul>
+   *
+   * @param changeId the ID of the change to which the diff object belongs
+   * @param str the string representation of the diff object
+   * @return the parsed diff object, {@code null} if str cannot be parsed as
+   *         diff object
+   */
+  public static DiffObject parse(Change.Id changeId, String str) {
+    if (str == null || str.isEmpty()) {
+      return new DiffObject(false);
+    }
+
+    if (AUTO_MERGE.equals(str)) {
+      return new DiffObject(true);
+    }
+
+    try {
+      return new DiffObject(new PatchSet.Id(changeId, Integer.parseInt(str)));
+    } catch (NumberFormatException e) {
+      return null;
+    }
+  }
+
+  /**
+   * Create a DiffObject that represents the parent of a 1-parent patch set.
+   */
+  public static DiffObject base() {
+    return new DiffObject(false);
+  }
+
+  /**
+   * Create a DiffObject that represents the auto-merge for a merge patch set.
+   */
+  public static DiffObject autoMerge() {
+    return new DiffObject(true);
+  }
+
+  /**
+   * Create a DiffObject that represents a patch set.
+   */
+  public static DiffObject patchSet(PatchSet.Id psId) {
+    return new DiffObject(psId);
+  }
+
+  private final PatchSet.Id psId;
+  private final boolean autoMerge;
+
+  private DiffObject(PatchSet.Id psId) {
+    this.psId = psId;
+    this.autoMerge = false;
+  }
+
+  private DiffObject(boolean autoMerge) {
+    this.psId = null;
+    this.autoMerge = autoMerge;
+  }
+
+  public boolean isBase() {
+    return psId == null && !autoMerge;
+  }
+
+  public boolean isAutoMerge() {
+    return psId == null && autoMerge;
+  }
+
+  public boolean isBaseOrAutoMerge() {
+    return psId == null;
+  }
+
+  public boolean isPatchSet() {
+    return psId != null && psId.get() > 0;
+  }
+
+  public boolean isParent() {
+    return psId != null && psId.get() < 0;
+  }
+
+  public boolean isEdit() {
+    return psId != null && psId.get() == 0;
+  }
+
+  /**
+   * Returns the DiffObject as PatchSet.Id.
+   *
+   * @return PatchSet.Id with an id > 0 for a regular patch set; PatchSet.Id
+   *         with an id < 0 for a parent of a merge; PatchSet.Id with id == 0
+   *         for an edit patch set; {@code null} for the base of a 1-parent
+   *         patch set and for the auto-merge of a merge patch set
+   */
+  public PatchSet.Id asPatchSetId() {
+    return psId;
+  }
+
+  /**
+   * Returns the parent number for a parent of a merge.
+   *
+   * @return 1-based parent number, 0 if this DiffObject is not a parent of a
+   *         merge
+   */
+  public int getParentNum() {
+    if (!isParent()) {
+      return 0;
+    }
+
+    return -psId.get();
+  }
+
+  /**
+   * Returns a string representation of this DiffObject that can be used in
+   * URLs.
+   * <p>
+   * The following string representations are returned:
+   * <ul>
+   * <li>a positive integer for a patch set
+   * <li>a negative integer for a parent of a merge patch set
+   * <li>'0' for the edit patch set
+   * <li>{@code null} for the parent of a 1-parent patch set, also called base
+   * <li>'AutoMerge' for the auto-merge of a merge patch set
+   * </ul>
+   *
+   * @return string representation of this DiffObject
+   */
+  public String asString() {
+    if (autoMerge) {
+      if (Gerrit.getUserPreferences()
+          .defaultBaseForMerges() != DefaultBase.AUTO_MERGE) {
+        return AUTO_MERGE;
+      }
+      return null;
+    }
+
+    if (psId != null) {
+      return psId.getId();
+    }
+
+    return null;
+  }
+
+  @Override
+  public String toString() {
+    if (isPatchSet()) {
+      return "Patch Set " + psId.getId();
+    }
+
+    if (isParent()) {
+      return "Parent " + psId.getId();
+    }
+
+    if (isEdit()) {
+      return "Edit Patch Set";
+    }
+
+    if (isAutoMerge()) {
+      return "Auto Merge";
+    }
+
+    return "Base";
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
index b7405c7..e2aba0a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
@@ -89,7 +89,7 @@
 import com.google.gerrit.client.documentation.DocScreen;
 import com.google.gerrit.client.editor.EditScreen;
 import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.groups.GroupInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.ui.Screen;
@@ -108,35 +108,35 @@
 import com.google.gwtorm.client.KeyUtil;
 
 public class Dispatcher {
-  public static String toPatch(PatchSet.Id diffBase,
+  public static String toPatch(DiffObject diffBase,
       PatchSet.Id revision, String fileName) {
     return toPatch("", diffBase, revision, fileName, null, 0);
   }
 
-  public static String toPatch(PatchSet.Id diffBase,
+  public static String toPatch(DiffObject diffBase,
       PatchSet.Id revision, String fileName, DisplaySide side, int line) {
     return toPatch("", diffBase, revision, fileName, side, line);
   }
 
-  public static String toSideBySide(PatchSet.Id diffBase, Patch.Key id) {
+  public static String toSideBySide(DiffObject diffBase, Patch.Key id) {
     return toPatch("sidebyside", diffBase, id);
   }
 
-  public static String toSideBySide(PatchSet.Id diffBase,
-      PatchSet.Id revision, String fileName) {
+  public static String toSideBySide(DiffObject diffBase, PatchSet.Id revision,
+      String fileName) {
     return toPatch("sidebyside", diffBase, revision, fileName, null, 0);
   }
 
-  public static String toUnified(PatchSet.Id diffBase,
+  public static String toUnified(DiffObject diffBase,
       PatchSet.Id revision, String fileName) {
     return toPatch("unified", diffBase, revision, fileName, null, 0);
   }
 
-  public static String toUnified(PatchSet.Id diffBase, Patch.Key id) {
+  public static String toUnified(DiffObject diffBase, Patch.Key id) {
     return toPatch("unified", diffBase, id);
   }
 
-  public static String toPatch(String type, PatchSet.Id diffBase, Patch.Key id) {
+  public static String toPatch(String type, DiffObject diffBase, Patch.Key id) {
     return toPatch(type, diffBase, id.getParentKey(), id.get(), null, 0);
   }
 
@@ -145,16 +145,16 @@
   }
 
   public static String toEditScreen(PatchSet.Id revision, String fileName, int line) {
-    return toPatch("edit", null, revision, fileName, null, line);
+    return toPatch("edit", DiffObject.base(), revision, fileName, null, line);
   }
 
-  private static String toPatch(String type, PatchSet.Id diffBase,
+  private static String toPatch(String type, DiffObject diffBase,
       PatchSet.Id revision, String fileName, DisplaySide side, int line) {
     Change.Id c = revision.getParentKey();
     StringBuilder p = new StringBuilder();
     p.append("/c/").append(c).append("/");
-    if (diffBase != null) {
-      p.append(diffBase.get()).append("..");
+    if (diffBase != null && diffBase.asString() != null) {
+      p.append(diffBase.asString()).append("..");
     }
     p.append(revision.getId()).append("/").append(KeyUtil.encode(fileName));
     if (type != null && !type.isEmpty()
@@ -395,7 +395,7 @@
         panel = null;
       }
       Gerrit.display(token, panel == null
-          ? new ChangeScreen(id, null, null, false, mode)
+          ? new ChangeScreen(id, DiffObject.base(), null, false, mode)
           : new NotFoundScreen());
       return;
     }
@@ -410,11 +410,14 @@
       rest = "";
     }
 
-    PatchSet.Id base = null;
+    DiffObject base = DiffObject.base();
     PatchSet.Id ps;
     int dotdot = psIdStr.indexOf("..");
     if (1 <= dotdot) {
-      base = new PatchSet.Id(id, Integer.parseInt(psIdStr.substring(0, dotdot)));
+      base = DiffObject.parse(id, psIdStr.substring(0, dotdot));
+      if (base == null) {
+        Gerrit.display(token, new NotFoundScreen());
+      }
       psIdStr = psIdStr.substring(dotdot + 2);
     }
     ps = toPsId(id, psIdStr);
@@ -438,9 +441,7 @@
       if (panel == null) {
         Gerrit.display(token,
             new ChangeScreen(id,
-                base != null
-                    ? String.valueOf(base.get())
-                    : null,
+                base,
                 String.valueOf(ps.get()), false, FileTable.Mode.REVIEW));
       } else {
         Gerrit.display(token, new NotFoundScreen());
@@ -464,7 +465,7 @@
   }
 
   private static void patch(String token,
-      PatchSet.Id baseId,
+      DiffObject base,
       Patch.Key id,
       DisplaySide side,
       int line,
@@ -477,16 +478,20 @@
 
     if ("".equals(panel) || /* DEPRECATED URL */"cm".equals(panel)) {
       if (preferUnified()) {
-        unified(token, baseId, id, side, line);
+        unified(token, base, id, side, line);
       } else {
-        codemirror(token, baseId, id, side, line, false);
+        codemirror(token, base, id, side, line);
       }
     } else if ("sidebyside".equals(panel)) {
-      codemirror(token, baseId, id, side, line, false);
+      codemirror(token, base, id, side, line);
     } else if ("unified".equals(panel)) {
-      unified(token, baseId, id, side, line);
+      unified(token, base, id, side, line);
     } else if ("edit".equals(panel)) {
-      codemirror(token, null, id, side, line, true);
+      if (!Patch.isMagic(id.get()) || Patch.COMMIT_MSG.equals(id.get())) {
+        codemirrorForEdit(token, id, line);
+      } else {
+        Gerrit.display(token, new NotFoundScreen());
+      }
     } else {
       Gerrit.display(token, new NotFoundScreen());
     }
@@ -497,26 +502,34 @@
         || (UserAgent.isPortrait() && UserAgent.isMobile());
   }
 
-  private static void unified(final String token, final PatchSet.Id baseId,
+  private static void unified(final String token, final DiffObject base,
       final Patch.Key id, final DisplaySide side, final int line) {
     GWT.runAsync(new AsyncSplit(token) {
       @Override
       public void onSuccess() {
-        Gerrit.display(token,
-            new Unified(baseId, id.getParentKey(), id.get(), side, line));
+        Gerrit.display(token, new Unified(base,
+            DiffObject.patchSet(id.getParentKey()), id.get(), side, line));
       }
     });
   }
 
-  private static void codemirror(final String token, final PatchSet.Id baseId,
-      final Patch.Key id, final DisplaySide side, final int line,
-      final boolean edit) {
+  private static void codemirror(final String token, final DiffObject base,
+      final Patch.Key id, final DisplaySide side, final int line) {
     GWT.runAsync(new AsyncSplit(token) {
       @Override
       public void onSuccess() {
-        Gerrit.display(token, edit
-            ? new EditScreen(baseId, id, line)
-            : new SideBySide(baseId, id.getParentKey(), id.get(), side, line));
+        Gerrit.display(token, new SideBySide(base,
+            DiffObject.patchSet(id.getParentKey()), id.get(), side, line));
+      }
+    });
+  }
+
+  private static void codemirrorForEdit(final String token, final Patch.Key id,
+      final int line) {
+    GWT.runAsync(new AsyncSplit(token) {
+      @Override
+      public void onSuccess() {
+        Gerrit.display(token, new EditScreen(id, line));
       }
     });
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
index dd1505c..3f0daa2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.client.change.Resources;
 import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gwt.i18n.client.NumberFormat;
 
 import java.util.Date;
@@ -84,17 +83,6 @@
     return createAccountFormatter().name(info);
   }
 
-  public static AccountInfo asInfo(Account acct) {
-    if (acct == null) {
-      return AccountInfo.create(0, null, null, null);
-    }
-    return AccountInfo.create(
-        acct.getId() != null ? acct.getId().get() : 0,
-        acct.getFullName(),
-        acct.getPreferredEmail(),
-        acct.getUserName());
-  }
-
   public static AccountInfo asInfo(com.google.gerrit.common.data.AccountInfo acct) {
     if (acct == null) {
       return AccountInfo.create(0, null, null, null);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
index d280e07..d52d192 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
@@ -44,12 +44,14 @@
 import com.google.gerrit.client.ui.MorphingTabPanel;
 import com.google.gerrit.client.ui.ProjectLinkMenuItem;
 import com.google.gerrit.client.ui.Screen;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.common.data.SystemInfoService;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GerritTopMenu;
+import com.google.gerrit.extensions.client.UiType;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.aria.client.Roles;
 import com.google.gwt.core.client.EntryPoint;
@@ -286,6 +288,7 @@
   }
 
   /** @return access token to prove user identity during REST API calls. */
+  @Nullable
   public static String getXGerritAuth() {
     return xGerritAuth;
   }
@@ -537,6 +540,14 @@
 
     btmmenu.add(new InlineHTML(M.poweredBy(vs)));
 
+    if (info().gerrit().webUis().contains(UiType.POLYGERRIT)) {
+      btmmenu.add(new InlineLabel(" | "));
+      Anchor a = new Anchor(
+          C.polyGerrit(), GWT.getHostPageBaseURL() + "?polygerrit=1");
+      a.setStyleName("");
+      btmmenu.add(a);
+    }
+
     String reportBugUrl = info().gerrit().reportBugUrl();
     if (reportBugUrl != null) {
       String reportBugText = info().gerrit().reportBugText();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java
index 4c8c58d..53d9260 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java
@@ -130,4 +130,6 @@
 
   String searchDropdownChanges();
   String searchDropdownDoc();
+
+  String polyGerrit();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties
index 10d7e1d..d50ab34 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties
@@ -113,3 +113,5 @@
 
 searchDropdownChanges = Changes
 searchDropdownDoc = Docs
+
+polyGerrit = PolyGerrit
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
index 32e30d4..30f33f1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
@@ -32,6 +32,8 @@
   String branchTableDeleteButton();
   String branchTablePrevNextLinks();
   String cAPPROVAL();
+  String cASSIGNEE();
+  String cASSIGNEDTOME();
   String cLastUpdate();
   String cOWNER();
   String cSIZE();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
index 54c5b92..000e5fd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
@@ -31,7 +31,7 @@
           new ProjectNameSuggestOracle()),
       new ParamSuggester(Arrays.asList(
           "owner:", "reviewer:", "commentby:", "reviewedby:", "author:",
-          "committer:", "from:"),
+          "committer:", "from:", "assignee:"),
           new AccountSuggestOracle() {
             @Override
             public void onRequestSuggestions(final Request request, final Callback done) {
@@ -139,6 +139,12 @@
       suggestions.add("hashtag:");
     }
 
+    if (Gerrit.info().change().showAssignee()) {
+      suggestions.add("is:assigned");
+      suggestions.add("is:unassigned");
+      suggestions.add("assignee:");
+    }
+
     suggestions.add("AND");
     suggestions.add("OR");
     suggestions.add("NOT");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
index acd2e78..9aca859 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.AgreementInfo;
 import com.google.gerrit.client.info.GpgKeyInfo;
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.NativeMap;
@@ -83,6 +84,14 @@
     new RestApi("/accounts/").id(account).view("name").get(cb);
   }
 
+  /** Set the account name */
+  public static void setName(String account, String name,
+      AsyncCallback<NativeString> cb) {
+    AccountNameInput input = AccountNameInput.create();
+    input.name(name);
+    new RestApi("/accounts/").id(account).view("name").put(input, cb);
+  }
+
   /** Retrieve email addresses */
   public static void getEmails(String account,
       AsyncCallback<JsArray<EmailInfo>> cb) {
@@ -97,6 +106,13 @@
         .ifNoneMatch().put(in, cb);
   }
 
+  /** Set preferred email address */
+  public static void setPreferredEmail(String account, String email,
+      AsyncCallback<NativeString> cb) {
+    new RestApi("/accounts/").id(account).view("emails")
+        .id(email).view("preferred").put(cb);
+  }
+
   /** Retrieve SSH keys */
   public static void getSshKeys(String account,
       AsyncCallback<JsArray<SshKeyInfo>> cb) {
@@ -196,6 +212,14 @@
     new RestApi("/accounts/").id(account).view("password.http").delete(cb);
   }
 
+  /** Enter a contributor agreement */
+  public static void enterAgreement(String account, String name,
+      AsyncCallback<NativeString> cb) {
+    AgreementInput in = AgreementInput.create();
+    in.name(name);
+    new RestApi("/accounts/").id(account).view("agreements").put(in, cb);
+  }
+
   private static JsArray<ProjectWatchInfo> projectWatchArrayFromSet(
       Set<ProjectWatchInfo> set) {
     JsArray<ProjectWatchInfo> jsArray = JsArray.createArray().cast();
@@ -205,6 +229,17 @@
     return jsArray;
   }
 
+  private static class AgreementInput extends JavaScriptObject {
+    final native void name(String n) /*-{ if(n)this.name=n; }-*/;
+
+    static AgreementInput create() {
+      return createObject().cast();
+    }
+
+    protected AgreementInput() {
+    }
+  }
+
   private static class HttpPasswordInput extends JavaScriptObject {
     final native void generate(boolean g) /*-{ if(g)this.generate=g; }-*/;
 
@@ -227,6 +262,17 @@
     }
   }
 
+  private static class AccountNameInput extends JavaScriptObject {
+    final native void name(String n) /*-{ if(n)this.name=n; }-*/;
+
+    static AccountNameInput create() {
+      return createObject().cast();
+    }
+
+    protected AccountNameInput() {
+    }
+  }
+
   public static void addGpgKey(String account, String armored,
       AsyncCallback<NativeMap<GpgKeyInfo>> cb) {
     new RestApi("/accounts/")
@@ -243,6 +289,12 @@
       .post(GpgKeysInput.delete(fingerprints), cb);
   }
 
+  /** List contributor agreements */
+  public static void getAgreements(String account,
+      AsyncCallback<JsArray<AgreementInfo>> cb) {
+    new RestApi("/accounts/").id(account).view("agreements").get(cb);
+  }
+
   private static class GpgKeysInput extends JavaScriptObject {
     static GpgKeysInput add(String key) {
       return createWithAdd(Natives.arrayOf(key));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
index a084612..06d5df5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
@@ -38,6 +38,7 @@
   String messageShowInReviewCategoryUsername();
   String messageShowInReviewCategoryAbbrev();
   String buttonSaveChanges();
+  String highlightAssigneeInChangeTable();
   String showRelativeDateInChangeTable();
   String showSizeBarInChangeTable();
   String showLegacycidInChangeTable();
@@ -141,11 +142,8 @@
   String errorDialogTitleRegisterNewEmail();
 
   String newAgreement();
-  String agreementStatus();
   String agreementName();
   String agreementDescription();
-  String agreementStatus_EXPIRED();
-  String agreementStatus_VERIFIED();
 
   String newAgreementSelectTypeHeading();
   String newAgreementNoneAvailable();
@@ -171,4 +169,8 @@
   String messageCCMeOnMyComments();
   String messageDisabled();
   String emailFieldLabel();
+
+  String defaultBaseForMerges();
+  String autoMerge();
+  String firstParent();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
index ca2d316..2479c87 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
@@ -15,15 +15,20 @@
 messageShowInReviewCategoryAbbrev = Show Abbreviated Name
 
 emailFieldLabel = Email Notifications:
-messageEnabled = Enabled
-messageCCMeOnMyComments = CC Me On Comments I Write
-messageDisabled = Disabled
+messageCCMeOnMyComments = Every Comment
+messageEnabled = Only Comments Left By Others
+messageDisabled = None
+
+defaultBaseForMerges = Default Base For Merges:
+autoMerge = Auto Merge
+firstParent = First Parent
 
 maximumPageSizeFieldLabel = Maximum Page Size:
 diffViewLabel = Diff View:
 dateFormatLabel = Date/Time Format:
 contextWholeFile = Whole File
 buttonSaveChanges = Save Changes
+highlightAssigneeInChangeTable = Highlight Changes Assigned To Me In Changes Table
 showRelativeDateInChangeTable = Show Relative Dates In Changes Table
 showSizeBarInChangeTable = Show Change Sizes As Colored Bars
 showLegacycidInChangeTable = Show Change Number In Changes Table
@@ -151,10 +156,7 @@
 
 
 newAgreement = New Contributor Agreement
-agreementStatus = Status
 agreementName = Name
-agreementStatus_EXPIRED = Expired
-agreementStatus_VERIFIED = Verified
 agreementDescription = Description
 
 newAgreementSelectTypeHeading = Select an agreement type:
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java
index ae3599d..0b8fe3e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.client.account;
 
 import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.FormatUtil;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.client.rpc.CallbackGroup;
@@ -25,8 +24,7 @@
 import com.google.gerrit.client.ui.OnEditEnabler;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.ChangeEvent;
 import com.google.gwt.event.dom.client.ChangeHandler;
@@ -46,7 +44,6 @@
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
 import com.google.gwtexpui.user.client.AutoCenterDialogBox;
-import com.google.gwtjsonrpc.common.AsyncCallback;
 
 class ContactPanelShort extends Composite {
   protected final FlowPanel body;
@@ -104,7 +101,7 @@
     }
 
     int row = 0;
-    if (!Gerrit.info().auth().canEdit(FieldName.USER_NAME)
+    if (!Gerrit.info().auth().canEdit(AccountFieldName.USER_NAME)
         && Gerrit.info().auth().siteHasUsernames()) {
       infoPlainText.resizeRows(infoPlainText.getRowCount() + 1);
       row(infoPlainText, row++, Util.C.userName(), new UsernameField());
@@ -146,7 +143,7 @@
     save.addClickHandler(new ClickHandler() {
       @Override
       public void onClick(final ClickEvent event) {
-        doSave(null);
+        doSave();
       }
     });
 
@@ -173,11 +170,11 @@
   }
 
   private boolean canEditFullName() {
-    return Gerrit.info().auth().canEdit(Account.FieldName.FULL_NAME);
+    return Gerrit.info().auth().canEdit(AccountFieldName.FULL_NAME);
   }
 
   private boolean canRegisterNewEmail() {
-    return Gerrit.info().auth().canEdit(Account.FieldName.REGISTER_NEW_EMAIL);
+    return Gerrit.info().auth().canEdit(AccountFieldName.REGISTER_NEW_EMAIL);
   }
 
   void hideSaveButton() {
@@ -347,10 +344,13 @@
     inEmail.setFocus(true);
   }
 
-  void doSave(final AsyncCallback<Account> onSave) {
-    String newName = canEditFullName() ? nameTxt.getText() : null;
-    if (newName != null && newName.trim().isEmpty()) {
+  void doSave() {
+    final String newName;
+    String name = canEditFullName() ? nameTxt.getText() : null;
+    if (name != null && name.trim().isEmpty()) {
       newName = null;
+    } else {
+      newName = name;
     }
 
     final String newEmail;
@@ -368,24 +368,40 @@
     save.setEnabled(false);
     registerNewEmail.setEnabled(false);
 
-    Util.ACCOUNT_SEC.updateContact(newName, newEmail,
-        new GerritCallback<Account>() {
-          @Override
-          public void onSuccess(Account result) {
-            registerNewEmail.setEnabled(true);
-            onSaveSuccess(FormatUtil.asInfo(result));
-            if (onSave != null) {
-              onSave.onSuccess(result);
-            }
-          }
+    CallbackGroup group = new CallbackGroup();
+    if (currentEmail != null && !newEmail.equals(currentEmail)) {
+      AccountApi.setPreferredEmail("self", newEmail,
+          group.add(new GerritCallback<NativeString>() {
+        @Override
+        public void onSuccess(NativeString result) {
+        }
+      }));
+    }
+    AccountApi.setName("self", newName,
+        group.add(new GerritCallback<NativeString>() {
+      @Override
+      public void onSuccess(NativeString result) {
+      }
 
-          @Override
-          public void onFailure(final Throwable caught) {
-            save.setEnabled(true);
-            registerNewEmail.setEnabled(true);
-            super.onFailure(caught);
-          }
-        });
+      @Override
+      public void onFailure(Throwable caught) {
+        save.setEnabled(true);
+        registerNewEmail.setEnabled(true);
+        super.onFailure(caught);
+      }
+    }));
+    group.done();
+    group.addListener(new GerritCallback<Void>() {
+      @Override
+      public void onSuccess(Void result) {
+        currentEmail = newEmail;
+        AccountInfo me = Gerrit.getUserAccount();
+        me.email(currentEmail);
+        me.name(newName);
+        onSaveSuccess(me);
+        registerNewEmail.setEnabled(true);
+      }
+    });
   }
 
   void onSaveSuccess(AccountInfo result) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java
index 7c707b2..423d05f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java
@@ -129,24 +129,24 @@
   public final native void lineLength(int c) /*-{ this.line_length = c }-*/;
   public final native void context(int c) /*-{ this.context = c }-*/;
   public final native void cursorBlinkRate(int r) /*-{ this.cursor_blink_rate = r }-*/;
-  public final native void intralineDifference(boolean i) /*-{ this.intraline_difference = i }-*/;
-  public final native void showLineEndings(boolean s) /*-{ this.show_line_endings = s }-*/;
-  public final native void showTabs(boolean s) /*-{ this.show_tabs = s }-*/;
-  public final native void showWhitespaceErrors(boolean s) /*-{ this.show_whitespace_errors = s }-*/;
-  public final native void syntaxHighlighting(boolean s) /*-{ this.syntax_highlighting = s }-*/;
-  public final native void hideTopMenu(boolean s) /*-{ this.hide_top_menu = s }-*/;
-  public final native void autoHideDiffTableHeader(boolean s) /*-{ this.auto_hide_diff_table_header = s }-*/;
-  public final native void hideLineNumbers(boolean s) /*-{ this.hide_line_numbers = s }-*/;
-  public final native void expandAllComments(boolean e) /*-{ this.expand_all_comments = e }-*/;
-  public final native void manualReview(boolean r) /*-{ this.manual_review = r }-*/;
-  public final native void renderEntireFile(boolean r) /*-{ this.render_entire_file = r }-*/;
-  public final native void retainHeader(boolean r) /*-{ this.retain_header = r }-*/;
-  public final native void hideEmptyPane(boolean s) /*-{ this.hide_empty_pane = s }-*/;
-  public final native void skipUnchanged(boolean s) /*-{ this.skip_unchanged = s }-*/;
-  public final native void skipUncommented(boolean s) /*-{ this.skip_uncommented = s }-*/;
-  public final native void skipDeleted(boolean s) /*-{ this.skip_deleted = s }-*/;
-  public final native void matchBrackets(boolean m) /*-{ this.match_brackets = m }-*/;
-  public final native void lineWrapping(boolean w) /*-{ this.line_wrapping = w }-*/;
+  public final native void intralineDifference(Boolean i) /*-{ this.intraline_difference = i }-*/;
+  public final native void showLineEndings(Boolean s) /*-{ this.show_line_endings = s }-*/;
+  public final native void showTabs(Boolean s) /*-{ this.show_tabs = s }-*/;
+  public final native void showWhitespaceErrors(Boolean s) /*-{ this.show_whitespace_errors = s }-*/;
+  public final native void syntaxHighlighting(Boolean s) /*-{ this.syntax_highlighting = s }-*/;
+  public final native void hideTopMenu(Boolean s) /*-{ this.hide_top_menu = s }-*/;
+  public final native void autoHideDiffTableHeader(Boolean s) /*-{ this.auto_hide_diff_table_header = s }-*/;
+  public final native void hideLineNumbers(Boolean s) /*-{ this.hide_line_numbers = s }-*/;
+  public final native void expandAllComments(Boolean e) /*-{ this.expand_all_comments = e }-*/;
+  public final native void manualReview(Boolean r) /*-{ this.manual_review = r }-*/;
+  public final native void renderEntireFile(Boolean r) /*-{ this.render_entire_file = r }-*/;
+  public final native void retainHeader(Boolean r) /*-{ this.retain_header = r }-*/;
+  public final native void hideEmptyPane(Boolean s) /*-{ this.hide_empty_pane = s }-*/;
+  public final native void skipUnchanged(Boolean s) /*-{ this.skip_unchanged = s }-*/;
+  public final native void skipUncommented(Boolean s) /*-{ this.skip_uncommented = s }-*/;
+  public final native void skipDeleted(Boolean s) /*-{ this.skip_deleted = s }-*/;
+  public final native void matchBrackets(Boolean m) /*-{ this.match_brackets = m }-*/;
+  public final native void lineWrapping(Boolean w) /*-{ this.line_wrapping = w }-*/;
   public final native boolean intralineDifference() /*-{ return this.intraline_difference || false }-*/;
   public final native boolean showLineEndings() /*-{ return this.show_line_endings || false }-*/;
   public final native boolean showTabs() /*-{ return this.show_tabs || false }-*/;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyAgreementsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyAgreementsScreen.java
index 308cf30..cd7c141 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyAgreementsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyAgreementsScreen.java
@@ -15,15 +15,19 @@
 package com.google.gerrit.client.account;
 
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.info.AgreementInfo;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.rpc.ScreenLoadCallback;
 import com.google.gerrit.client.ui.FancyFlexTable;
 import com.google.gerrit.client.ui.Hyperlink;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.AgreementInfo;
 import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 
+import java.util.List;
+
 public class MyAgreementsScreen extends SettingsScreen {
   private AgreementTable agreements;
 
@@ -39,10 +43,11 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    Util.ACCOUNT_SVC.myAgreements(new ScreenLoadCallback<AgreementInfo>(this) {
+    AccountApi.getAgreements(
+        "self", new ScreenLoadCallback<JsArray<AgreementInfo>>(this) {
       @Override
-      public void preDisplay(final AgreementInfo result) {
-        agreements.display(result);
+      public void preDisplay(JsArray<AgreementInfo> result) {
+        agreements.display(Natives.asList(result));
       }
     });
   }
@@ -50,60 +55,43 @@
   private static class AgreementTable extends FancyFlexTable<ContributorAgreement> {
     AgreementTable() {
       table.setWidth("");
-      table.setText(0, 1, Util.C.agreementStatus());
-      table.setText(0, 2, Util.C.agreementName());
-      table.setText(0, 3, Util.C.agreementDescription());
+      table.setText(0, 1, Util.C.agreementName());
+      table.setText(0, 2, Util.C.agreementDescription());
 
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      for (int c = 1; c < 4; c++) {
+      FlexCellFormatter fmt = table.getFlexCellFormatter();
+      for (int c = 1; c < 3; c++) {
         fmt.addStyleName(0, c, Gerrit.RESOURCES.css().dataHeader());
       }
     }
 
-    void display(final AgreementInfo result) {
+    void display(List<AgreementInfo> result) {
       while (1 < table.getRowCount()) {
         table.removeRow(table.getRowCount() - 1);
       }
 
-      for (final String k : result.accepted) {
-        addOne(result, k);
+      for (AgreementInfo info : result) {
+        addOne(info);
       }
     }
 
-    void addOne(final AgreementInfo info, final String k) {
-      final int row = table.getRowCount();
+    void addOne(AgreementInfo info) {
+      int row = table.getRowCount();
       table.insertRow(row);
       applyDataRowStyle(row);
 
-      final ContributorAgreement cla = info.agreements.get(k);
-      final String statusName;
-      if (cla == null) {
-        statusName = Util.C.agreementStatus_EXPIRED();
+      String url = info.url();
+      if (url != null && url.length() > 0) {
+        Anchor a = new Anchor(info.name(), url);
+        a.setTarget("_blank");
+        table.setWidget(row, 1, a);
       } else {
-        statusName = Util.C.agreementStatus_VERIFIED();
+        table.setText(row, 1, info.name());
       }
-      table.setText(row, 1, statusName);
-
-      if (cla == null) {
-        table.setText(row, 2, "");
-        table.setText(row, 3, "");
-      } else {
-        final String url = cla.getAgreementUrl();
-        if (url != null && url.length() > 0) {
-          final Anchor a = new Anchor(cla.getName(), url);
-          a.setTarget("_blank");
-          table.setWidget(row, 2, a);
-        } else {
-          table.setText(row, 2, cla.getName());
-        }
-        table.setText(row, 3, cla.getDescription());
-      }
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      for (int c = 1; c < 4; c++) {
+      table.setText(row, 2, info.description());
+      FlexCellFormatter fmt = table.getFlexCellFormatter();
+      for (int c = 1; c < 3; c++) {
         fmt.addStyleName(row, c, Gerrit.RESOURCES.css().dataCell());
       }
-
-      setRowItem(row, cla);
     }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
index 2b01b59..3bfc7da 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
@@ -50,6 +50,7 @@
 public class MyPreferencesScreen extends SettingsScreen {
   private CheckBox showSiteHeader;
   private CheckBox useFlashClipboard;
+  private CheckBox highlightAssigneeInChangeTable;
   private CheckBox relativeDateInChangeTable;
   private CheckBox sizeBarInChangeTable;
   private CheckBox legacycidInChangeTable;
@@ -61,6 +62,7 @@
   private ListBox reviewCategoryStrategy;
   private ListBox diffView;
   private ListBox emailStrategy;
+  private ListBox defaultBaseForMerges;
   private StringListPanel myMenus;
   private Button save;
 
@@ -93,19 +95,25 @@
         GeneralPreferencesInfo.ReviewCategoryStrategy.ABBREV.name());
 
     emailStrategy = new ListBox();
-    emailStrategy.addItem(Util.C.messageEnabled(),
-        GeneralPreferencesInfo.EmailStrategy.ENABLED.name());
     emailStrategy
         .addItem(
             Util.C.messageCCMeOnMyComments(),
             GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS
                 .name());
+    emailStrategy.addItem(Util.C.messageEnabled(),
+        GeneralPreferencesInfo.EmailStrategy.ENABLED.name());
     emailStrategy
         .addItem(
             Util.C.messageDisabled(),
             GeneralPreferencesInfo.EmailStrategy.DISABLED
                 .name());
 
+    defaultBaseForMerges = new ListBox();
+    defaultBaseForMerges.addItem(Util.C.autoMerge(),
+        GeneralPreferencesInfo.DefaultBase.AUTO_MERGE.name());
+    defaultBaseForMerges.addItem(Util.C.firstParent(),
+        GeneralPreferencesInfo.DefaultBase.FIRST_PARENT.name());
+
     diffView = new ListBox();
     diffView.addItem(
         com.google.gerrit.client.changes.Util.C.sideBySide(),
@@ -148,7 +156,8 @@
       dateTimePanel.add(dateFormat);
       dateTimePanel.add(timeFormat);
     }
-
+    highlightAssigneeInChangeTable = new CheckBox(Util.C.highlightAssigneeInChangeTable());
+    highlightAssigneeInChangeTable.setEnabled(Gerrit.info().change().showAssignee());
     relativeDateInChangeTable = new CheckBox(Util.C.showRelativeDateInChangeTable());
     sizeBarInChangeTable = new CheckBox(Util.C.showSizeBarInChangeTable());
     legacycidInChangeTable = new CheckBox(Util.C.showLegacycidInChangeTable());
@@ -156,7 +165,7 @@
     signedOffBy = new CheckBox(Util.C.signedOffBy());
 
     boolean flashClippy = !UserAgent.hasJavaScriptClipboard() && UserAgent.Flash.isInstalled();
-    final Grid formGrid = new Grid(12 + (flashClippy ? 1 : 0), 2);
+    final Grid formGrid = new Grid(13 + (flashClippy ? 1 : 0), 2);
 
     int row = 0;
 
@@ -176,6 +185,10 @@
     formGrid.setWidget(row, fieldIdx, emailStrategy);
     row++;
 
+    formGrid.setText(row, labelIdx, Util.C.defaultBaseForMerges());
+    formGrid.setWidget(row, fieldIdx, defaultBaseForMerges);
+    row++;
+
     formGrid.setText(row, labelIdx, Util.C.diffViewLabel());
     formGrid.setWidget(row, fieldIdx, diffView);
     row++;
@@ -185,6 +198,10 @@
     row++;
 
     formGrid.setText(row, labelIdx, "");
+    formGrid.setWidget(row, fieldIdx, highlightAssigneeInChangeTable);
+    row++;
+
+    formGrid.setText(row, labelIdx, "");
     formGrid.setWidget(row, fieldIdx, relativeDateInChangeTable);
     row++;
 
@@ -231,6 +248,7 @@
     e.listenTo(maximumPageSize);
     e.listenTo(dateFormat);
     e.listenTo(timeFormat);
+    e.listenTo(highlightAssigneeInChangeTable);
     e.listenTo(relativeDateInChangeTable);
     e.listenTo(sizeBarInChangeTable);
     e.listenTo(legacycidInChangeTable);
@@ -239,6 +257,7 @@
     e.listenTo(diffView);
     e.listenTo(reviewCategoryStrategy);
     e.listenTo(emailStrategy);
+    e.listenTo(defaultBaseForMerges);
   }
 
   @Override
@@ -264,6 +283,7 @@
     maximumPageSize.setEnabled(on);
     dateFormat.setEnabled(on);
     timeFormat.setEnabled(on);
+    highlightAssigneeInChangeTable.setEnabled(Gerrit.info().change().showAssignee());
     relativeDateInChangeTable.setEnabled(on);
     sizeBarInChangeTable.setEnabled(on);
     legacycidInChangeTable.setEnabled(on);
@@ -272,6 +292,7 @@
     reviewCategoryStrategy.setEnabled(on);
     diffView.setEnabled(on);
     emailStrategy.setEnabled(on);
+    defaultBaseForMerges.setEnabled(on);
   }
 
   private void display(GeneralPreferences p) {
@@ -282,6 +303,7 @@
         p.dateFormat());
     setListBox(timeFormat, GeneralPreferencesInfo.TimeFormat.HHMM_12, //
         p.timeFormat());
+    highlightAssigneeInChangeTable.setValue(p.highlightAssigneeInChangeTable());
     relativeDateInChangeTable.setValue(p.relativeDateInChangeTable());
     sizeBarInChangeTable.setValue(p.sizeBarInChangeTable());
     legacycidInChangeTable.setValue(p.legacycidInChangeTable());
@@ -296,6 +318,9 @@
     setListBox(emailStrategy,
         GeneralPreferencesInfo.EmailStrategy.ENABLED,
         p.emailStrategy());
+    setListBox(defaultBaseForMerges,
+        GeneralPreferencesInfo.DefaultBase.FIRST_PARENT,
+        p.defaultBaseForMerges());
     display(p.my());
   }
 
@@ -369,6 +394,7 @@
     p.timeFormat(getListBox(timeFormat,
         GeneralPreferencesInfo.TimeFormat.HHMM_12,
         GeneralPreferencesInfo.TimeFormat.values()));
+    p.highlightAssigneeInChangeTable(highlightAssigneeInChangeTable.getValue());
     p.relativeDateInChangeTable(relativeDateInChangeTable.getValue());
     p.sizeBarInChangeTable(sizeBarInChangeTable.getValue());
     p.legacycidInChangeTable(legacycidInChangeTable.getValue());
@@ -385,6 +411,10 @@
         GeneralPreferencesInfo.EmailStrategy.ENABLED,
         GeneralPreferencesInfo.EmailStrategy.values()));
 
+    p.defaultBaseForMerges(getListBox(defaultBaseForMerges,
+        GeneralPreferencesInfo.DefaultBase.FIRST_PARENT,
+        GeneralPreferencesInfo.DefaultBase.values()));
+
     List<TopMenuItem> items = new ArrayList<>();
     for (List<String> v : myMenus.getValues()) {
       items.add(TopMenuItem.create(v.get(0), v.get(1)));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java
index 14f8e2f..e7fa14c5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java
@@ -16,14 +16,16 @@
 
 import com.google.gerrit.client.ErrorDialog;
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.info.AgreementInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.NativeString;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.AccountScreen;
 import com.google.gerrit.client.ui.OnEditEnabler;
 import com.google.gerrit.client.ui.SmallHeading;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.AgreementInfo;
-import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gwt.core.client.GWT;
+import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.http.client.Request;
@@ -41,7 +43,6 @@
 import com.google.gwt.user.client.ui.RadioButton;
 import com.google.gwt.user.client.ui.VerticalPanel;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
-import com.google.gwtjsonrpc.common.VoidResult;
 
 import java.util.HashSet;
 import java.util.List;
@@ -50,8 +51,8 @@
 public class NewAgreementScreen extends AccountScreen {
   private final String nextToken;
   private Set<String> mySigned;
-  private List<ContributorAgreement> available;
-  private ContributorAgreement current;
+  private List<AgreementInfo> available;
+  private AgreementInfo current;
 
   private VerticalPanel radios;
 
@@ -73,25 +74,22 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    Util.ACCOUNT_SVC.myAgreements(new GerritCallback<AgreementInfo>() {
+    AccountApi.getAgreements(
+        "self", new GerritCallback<JsArray<AgreementInfo>>() {
       @Override
-      public void onSuccess(AgreementInfo result) {
+      public void onSuccess(JsArray<AgreementInfo> result) {
         if (isAttached()) {
-          mySigned = new HashSet<>(result.accepted);
+          mySigned = new HashSet<>();
+          for (AgreementInfo info: Natives.asList(result)) {
+            mySigned.add(info.name());
+          }
           postRPC();
         }
       }
     });
-    Gerrit.SYSTEM_SVC
-        .contributorAgreements(new GerritCallback<List<ContributorAgreement>>() {
-          @Override
-          public void onSuccess(final List<ContributorAgreement> result) {
-            if (isAttached()) {
-              available = result;
-              postRPC();
-            }
-          }
-        });
+
+    available = Gerrit.info().auth().contributorAgreements();
+    postRPC();
   }
 
   @Override
@@ -158,12 +156,12 @@
     }
     radios.add(hdr);
 
-    for (final ContributorAgreement cla : available) {
-      final RadioButton r = new RadioButton("cla_id", cla.getName());
+    for (final AgreementInfo cla : available) {
+      final RadioButton r = new RadioButton("cla_id", cla.name());
       r.addStyleName(Gerrit.RESOURCES.css().contributorAgreementButton());
       radios.add(r);
 
-      if (mySigned.contains(cla.getName())) {
+      if (mySigned.contains(cla.name())) {
         r.setEnabled(false);
         final Label l = new Label(Util.C.newAgreementAlreadySubmitted());
         l.setStyleName(Gerrit.RESOURCES.css().contributorAgreementAlreadySubmitted());
@@ -177,8 +175,8 @@
         });
       }
 
-      if (cla.getDescription() != null && !cla.getDescription().equals("")) {
-        final Label l = new Label(cla.getDescription());
+      if (cla.description() != null && !cla.description().equals("")) {
+        final Label l = new Label(cla.description());
         l.setStyleName(Gerrit.RESOURCES.css().contributorAgreementShortDescription());
         radios.add(l);
       }
@@ -199,24 +197,24 @@
   }
 
   private void doEnterAgreement() {
-    Util.ACCOUNT_SEC.enterAgreement(current.getName(),
-        new GerritCallback<VoidResult>() {
+    AccountApi.enterAgreement("self", current.name(),
+        new GerritCallback<NativeString>() {
           @Override
-          public void onSuccess(final VoidResult result) {
+          public void onSuccess(NativeString result) {
             Gerrit.display(nextToken);
           }
 
           @Override
-          public void onFailure(final Throwable caught) {
+          public void onFailure(Throwable caught) {
             yesIAgreeBox.setText("");
             super.onFailure(caught);
           }
         });
   }
 
-  private void showCLA(final ContributorAgreement cla) {
+  private void showCLA(AgreementInfo cla) {
     current = cla;
-    String url = cla.getAgreementUrl();
+    String url = cla.url();
     if (url != null && url.length() > 0) {
       agreementGroup.setVisible(true);
       agreementHtml.setText(Gerrit.C.rpcStatusWorking());
@@ -250,7 +248,7 @@
       agreementGroup.setVisible(false);
     }
 
-    finalGroup.setVisible(cla.getAutoVerify() != null);
+    finalGroup.setVisible(cla.autoVerifyGroup() != null);
     yesIAgreeBox.setText("");
     submit.setEnabled(false);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java
index c32a846..73557aa 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.client.ui.SmallHeading;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gwt.i18n.client.LocaleInfo;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.FormPanel;
@@ -70,7 +70,7 @@
     formBody.add(contactGroup);
 
     if (Gerrit.getUserAccount().username() == null
-        && Gerrit.info().auth().canEdit(FieldName.USER_NAME)) {
+        && Gerrit.info().auth().canEdit(AccountFieldName.USER_NAME)) {
       final FlowPanel fp = new FlowPanel();
       fp.setStyleName(Gerrit.RESOURCES.css().registerScreenSection());
       fp.add(new SmallHeading(Util.C.welcomeUsernameHeading()));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
index f388436..d70121b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.ui.OnEditEnabler;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
@@ -86,7 +87,7 @@
   }
 
   private boolean canEditUserName() {
-    return Gerrit.info().auth().canEdit(Account.FieldName.USER_NAME);
+    return Gerrit.info().auth().canEdit(AccountFieldName.USER_NAME);
   }
 
   private void confirmSetUserName() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/Util.java
index a0f36b9..b4b4390 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/Util.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/Util.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.client.account;
 
 import com.google.gerrit.common.data.AccountSecurity;
-import com.google.gerrit.common.data.AccountService;
 import com.google.gerrit.common.data.ProjectAdminService;
 import com.google.gwt.core.client.GWT;
 import com.google.gwtjsonrpc.client.JsonUtil;
@@ -23,14 +22,10 @@
 public class Util {
   public static final AccountConstants C = GWT.create(AccountConstants.class);
   public static final AccountMessages M = GWT.create(AccountMessages.class);
-  public static final AccountService ACCOUNT_SVC;
   public static final AccountSecurity ACCOUNT_SEC;
   public static final ProjectAdminService PROJECT_SVC;
 
   static {
-    ACCOUNT_SVC = GWT.create(AccountService.class);
-    JsonUtil.bind(ACCOUNT_SVC, "rpc/AccountService");
-
     ACCOUNT_SEC = GWT.create(AccountSecurity.class);
     JsonUtil.bind(ACCOUNT_SEC, "rpc/AccountSecurity");
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupAuditLogScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupAuditLogScreen.java
index 254d3e6..7a32f01 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupAuditLogScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupAuditLogScreen.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.groups.GroupApi;
 import com.google.gerrit.client.groups.GroupAuditEventInfo;
-import com.google.gerrit.client.groups.GroupInfo;
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.FancyFlexTable;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
index a71dffe..22a57a4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.groups.GroupInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.ui.AccountGroupSuggestOracle;
 import com.google.gerrit.client.ui.OnEditEnabler;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
index 7c0c8f6..eacff7b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.groups.GroupInfo;
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.AccountGroupSuggestOracle;
@@ -315,7 +315,7 @@
       CheckBox checkBox = new CheckBox();
       table.setWidget(row, 1, checkBox);
       checkBox.setEnabled(enabled);
-      table.setWidget(row, 2, new AccountLinkPanel(i));
+      table.setWidget(row, 2, AccountLinkPanel.create(i));
       table.setText(row, 3, i.email());
 
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java
index 8c00ba7..cbe8a06 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java
@@ -17,7 +17,7 @@
 import static com.google.gerrit.client.Dispatcher.toGroup;
 
 import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.groups.GroupInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.ui.MenuScreen;
 import com.google.gerrit.reviewdb.client.AccountGroup;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
index 984c5a3..322af1b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
@@ -77,6 +77,7 @@
   String projectSubmitType_MERGE_ALWAYS();
   String projectSubmitType_MERGE_IF_NECESSARY();
   String projectSubmitType_REBASE_IF_NECESSARY();
+  String projectSubmitType_REBASE_ALWAYS();
   String projectSubmitType_CHERRY_PICK();
 
   String headingProjectState();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
index 2fe5978..1ae3a16 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
@@ -54,6 +54,7 @@
 headingProjectSubmitType = Submit Type
 projectSubmitType_FAST_FORWARD_ONLY = Fast Forward Only
 projectSubmitType_MERGE_IF_NECESSARY = Merge if Necessary
+projectSubmitType_REBASE_ALWAYS = Rebase Always
 projectSubmitType_REBASE_IF_NECESSARY = Rebase if Necessary
 projectSubmitType_MERGE_ALWAYS = Always Merge
 projectSubmitType_CHERRY_PICK = Cherry Pick
@@ -123,7 +124,11 @@
 	abandon, \
 	addPatchSet, \
 	create, \
+	createTag, \
+	createSignedTag, \
+	delete, \
 	deleteDrafts, \
+	editAssignee, \
 	editHashtags, \
 	editTopicName, \
 	forgeAuthor, \
@@ -133,8 +138,6 @@
 	publishDrafts, \
 	push, \
 	pushMerge, \
-	pushTag, \
-	pushSignedTag, \
 	read, \
 	rebase, \
 	removeReviewer, \
@@ -145,7 +148,11 @@
 abandon = Abandon
 addPatchSet = Add Patch Set
 create = Create Reference
+createTag = Create Annotated Tag
+createSignedTag = Create Signed Tag
+delete = Delete Reference
 deleteDrafts = Delete Drafts
+editAssignee = Edit Assignee
 editHashtags = Edit Hashtags
 editTopicName = Edit Topic Name
 forgeAuthor = Forge Author Identity
@@ -155,8 +162,6 @@
 publishDrafts = Publish Drafts
 push = Push
 pushMerge = Push Merge Commit
-pushTag = Push Annotated Tag
-pushSignedTag = Push Signed Tag
 read = Read
 rebase = Rebase
 removeReviewer = Remove Reviewer
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java
index a2ba5cd..4efaa61 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.client.NotFoundScreen;
 import com.google.gerrit.client.account.AccountCapabilities;
 import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.groups.GroupInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.ui.OnEditEnabler;
 import com.google.gerrit.client.ui.Screen;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
index 64fc0e5..94d15bd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
@@ -18,9 +18,9 @@
 
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.groups.GroupInfo;
 import com.google.gerrit.client.groups.GroupList;
 import com.google.gerrit.client.groups.GroupMap;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.HighlightingInlineHyperlink;
 import com.google.gerrit.client.ui.NavigationTable;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PaginatedProjectScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PaginatedProjectScreen.java
index 6349803..66738c0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PaginatedProjectScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PaginatedProjectScreen.java
@@ -21,7 +21,7 @@
 
 abstract class PaginatedProjectScreen extends ProjectScreen {
   protected int pageSize;
-  protected String match;
+  protected String match = "";
   protected int start;
 
   PaginatedProjectScreen(Project.NameKey toShow) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.java
index f66307c..be5bdcb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
 import static com.google.gerrit.common.data.Permission.PUSH;
-import static com.google.gerrit.common.data.Permission.PUSH_TAG;
 
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
@@ -143,7 +142,7 @@
     initWidget(uiBinder.createAndBindUi(this));
 
     String name = permission.getName();
-    boolean canForce = PUSH.equals(name) || PUSH_TAG.equals(name);
+    boolean canForce = PUSH.equals(name);
     if (canForce) {
       String ref = section.getName();
       canForce = !ref.startsWith("refs/for/") && !ref.startsWith("^refs/for/");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
index 81286ea..3a46203 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
@@ -43,6 +43,8 @@
         return C.projectSubmitType_MERGE_IF_NECESSARY();
       case REBASE_IF_NECESSARY:
         return C.projectSubmitType_REBASE_IF_NECESSARY();
+      case REBASE_ALWAYS:
+        return C.projectSubmitType_REBASE_ALWAYS();
       case MERGE_ALWAYS:
         return C.projectSubmitType_MERGE_ALWAYS();
       case CHERRY_PICK:
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
new file mode 100644
index 0000000..391cfbf
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
@@ -0,0 +1,223 @@
+// Copyright (C) 2016 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.client.change;
+
+import com.google.gerrit.client.FormatUtil;
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.NotSignedInDialog;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.changes.Util;
+import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.ChangeInfo;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.ui.InlineHyperlink;
+import com.google.gerrit.client.ui.RemoteSuggestBox;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.ClickHandler;
+import com.google.gwt.event.logical.shared.CloseEvent;
+import com.google.gwt.event.logical.shared.CloseHandler;
+import com.google.gwt.event.logical.shared.SelectionEvent;
+import com.google.gwt.event.logical.shared.SelectionHandler;
+import com.google.gwt.uibinder.client.UiBinder;
+import com.google.gwt.uibinder.client.UiField;
+import com.google.gwt.uibinder.client.UiHandler;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.EventListener;
+import com.google.gwt.user.client.rpc.StatusCodeException;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.Image;
+import com.google.gwt.user.client.ui.UIObject;
+
+/**
+ * Edit assignee using auto-completion.
+ */
+public class Assignee extends Composite {
+  interface Binder extends UiBinder<HTMLPanel, Assignee> {
+  }
+
+  private static final Binder uiBinder = GWT.create(Binder.class);
+
+  @UiField Element show;
+  @UiField InlineHyperlink assigneeLink;
+  @UiField Image editAssigneeIcon;
+  @UiField Element form;
+  @UiField Element error;
+  @UiField(provided = true)
+  RemoteSuggestBox suggestBox;
+
+  private AssigneeSuggestOracle assigneeSuggestOracle;
+  private Change.Id changeId;
+  private boolean canEdit;
+  private AccountInfo currentAssignee;
+
+  Assignee() {
+    assigneeSuggestOracle = new AssigneeSuggestOracle();
+    suggestBox = new RemoteSuggestBox(assigneeSuggestOracle);
+    suggestBox.setVisibleLength(55);
+    suggestBox.setHintText(Util.C.approvalTableEditAssigneeHint());
+    suggestBox.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
+      @Override
+      public void onClose(CloseEvent<RemoteSuggestBox> event) {
+        Assignee.this.onCancel(null);
+      }
+    });
+    suggestBox.addSelectionHandler(new SelectionHandler<String>() {
+      @Override
+      public void onSelection(SelectionEvent<String> event) {
+        editAssignee(event.getSelectedItem());
+      }
+    });
+
+    initWidget(uiBinder.createAndBindUi(this));
+    editAssigneeIcon.addDomHandler(new ClickHandler() {
+      @Override
+      public void onClick(ClickEvent event) {
+        onOpenForm();
+      }
+    }, ClickEvent.getType());
+  }
+
+  void set(ChangeInfo info) {
+    this.changeId = info.legacyId();
+    this.canEdit = info.hasActions() && info.actions().containsKey("assignee");
+    setAssignee(info.assignee());
+    assigneeSuggestOracle.setChange(changeId);
+    editAssigneeIcon.setVisible(canEdit);
+    if (!canEdit) {
+      show.setTitle(null);
+    }
+  }
+
+  void onOpenForm() {
+    UIObject.setVisible(form, true);
+    UIObject.setVisible(show, false);
+    UIObject.setVisible(error, false);
+    editAssigneeIcon.setVisible(false);
+    suggestBox.setFocus(true);
+    if (currentAssignee != null) {
+      suggestBox.setText(FormatUtil.nameEmail(currentAssignee));
+      suggestBox.selectAll();
+    }
+  }
+
+  void onCloseForm() {
+    UIObject.setVisible(form, false);
+    UIObject.setVisible(show, true);
+    UIObject.setVisible(error, false);
+    editAssigneeIcon.setVisible(true);
+    suggestBox.setFocus(false);
+  }
+
+  @UiHandler("assign")
+  void onEditAssignee(@SuppressWarnings("unused") ClickEvent e) {
+    if (canEdit) {
+      editAssignee(suggestBox.getText());
+    }
+  }
+
+  @UiHandler("cancel")
+  void onCancel(@SuppressWarnings("unused") ClickEvent e) {
+    onCloseForm();
+  }
+
+  private void editAssignee(final String assignee) {
+    if (assignee.isEmpty()) {
+      ChangeApi.deleteAssignee(changeId.get(),
+          new GerritCallback<AccountInfo>() {
+            @Override
+            public void onSuccess(AccountInfo result) {
+              onCloseForm();
+              setAssignee(null);
+            }
+
+            @Override
+            public void onFailure(Throwable err) {
+              if (isSigninFailure(err)) {
+                new NotSignedInDialog().center();
+              } else {
+                UIObject.setVisible(error, true);
+                error.setInnerText(err instanceof StatusCodeException
+                    ? ((StatusCodeException) err).getEncodedResponse()
+                    : err.getMessage());
+              }
+            }
+          });
+    } else {
+      ChangeApi.setAssignee(changeId.get(), assignee,
+          new GerritCallback<AccountInfo>() {
+            @Override
+            public void onSuccess(AccountInfo result) {
+              onCloseForm();
+              setAssignee(result);
+              Reviewers reviewers = getReviewers();
+              if (reviewers != null) {
+                reviewers.updateReviewerList();
+              }
+            }
+
+            @Override
+            public void onFailure(Throwable err) {
+              if (isSigninFailure(err)) {
+                new NotSignedInDialog().center();
+              } else {
+                UIObject.setVisible(error, true);
+                error.setInnerText(err instanceof StatusCodeException
+                    ? ((StatusCodeException) err).getEncodedResponse()
+                    : err.getMessage());
+              }
+            }
+          });
+    }
+  }
+
+  private void setAssignee(AccountInfo assignee) {
+    currentAssignee = assignee;
+    assigneeLink.setText(assignee != null ? getName(assignee) : null);
+    assigneeLink.setTargetHistoryToken(assignee != null
+        ? PageLinks.toAssigneeQuery(assignee.name() != null
+            ? assignee.name()
+            : assignee.email() != null
+                ? assignee.email()
+                : String.valueOf(assignee._accountId()))
+        : "");
+  }
+
+  private Reviewers getReviewers() {
+      Element e = DOM.getParent(getElement());
+      for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
+        EventListener l = DOM.getEventListener(e);
+        if (l instanceof ChangeScreen) {
+          ChangeScreen screen =  (ChangeScreen) l;
+          return screen.reviewers;
+        }
+      }
+      return null;
+  }
+
+  private String getName(AccountInfo info) {
+    if (info.name() != null) {
+      return info.name();
+    }
+    if (info.email() != null) {
+      return info.email();
+    }
+    return Gerrit.info().user().anonymousCowardName();
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.ui.xml
new file mode 100644
index 0000000..d5a7239
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.ui.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Copyright (C) 2016 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.
+-->
+<ui:UiBinder
+    xmlns:ui='urn:ui:com.google.gwt.uibinder'
+    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
+    xmlns:g='urn:import:com.google.gwt.user.client.ui'
+    xmlns:u='urn:import:com.google.gerrit.client.ui'>
+  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
+  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
+  <ui:style gss='false'>
+    .suggestBox {
+      margin-bottom: 2px;
+    }
+
+    .error {
+      color: #D33D3D;
+      font-weight: bold;
+    }
+
+    .editAssignee,
+    .cancel {
+      cursor: pointer;
+      float: right;
+    }
+  </ui:style>
+  <g:HTMLPanel>
+    <div ui:field='show'>
+      <u:InlineHyperlink ui:field='assigneeLink'
+          title='Search for changes assigned to this user'/>
+      <g:Image ui:field='editAssigneeIcon'
+          resource='{ico.editUser}'
+          styleName='{style.editAssignee}'
+          title='Assign User to Change'/>
+    </div>
+    <div ui:field='form' style='display: none' aria-hidden='true'>
+      <u:RemoteSuggestBox ui:field='suggestBox' styleName='{style.suggestBox}'/>
+      <div ui:field='error'
+           class='{style.error}'
+           style='display: none' aria-hidden='true'/>
+      <div>
+        <g:Button ui:field='assign' styleName='{res.style.button}'>
+          <div>Assign</div>
+        </g:Button>
+        <g:Button ui:field='cancel'
+            styleName='{res.style.button}'
+            addStyleNames='{style.cancel}'>
+          <div>Cancel</div>
+        </g:Button>
+      </div>
+    </div>
+   </g:HTMLPanel>
+  </ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AssigneeSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AssigneeSuggestOracle.java
new file mode 100644
index 0000000..e505ad2
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AssigneeSuggestOracle.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2016 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.client.change;
+
+import com.google.gerrit.client.change.ReviewerSuggestOracle.RestReviewerSuggestion;
+import com.google.gerrit.client.change.ReviewerSuggestOracle.SuggestReviewerInfo;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.client.ui.SuggestAfterTypingNCharsOracle;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwt.core.client.JsArray;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** REST API based suggestion Oracle for assignee */
+public class AssigneeSuggestOracle extends SuggestAfterTypingNCharsOracle {
+  private Change.Id changeId;
+
+  public void setChange(Change.Id changeId) {
+    this.changeId = changeId;
+  }
+
+  @Override
+  protected void _onRequestSuggestions(Request req, Callback cb) {
+    ChangeApi
+        .suggestReviewers(changeId.get(), req.getQuery(), req.getLimit(), true)
+        .get(new GerritCallback<JsArray<SuggestReviewerInfo>>() {
+          @Override
+          public void onSuccess(JsArray<SuggestReviewerInfo> result) {
+            List<RestReviewerSuggestion> r = new ArrayList<>(result.length());
+            for (SuggestReviewerInfo reviewer : Natives.asList(result)) {
+              r.add(new RestReviewerSuggestion(reviewer, req.getQuery()));
+            }
+            cb.onSuggestionsReady(req, new Response(r));
+          }
+
+          @Override
+          public void onFailure(Throwable err) {
+            List<Suggestion> r = Collections.emptyList();
+            cb.onSuggestionsReady(req, new Response(r));
+          }
+        });
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java
index 63de389..99f3b9f2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java
@@ -51,6 +51,6 @@
   String abandoned();
 
   String deleteChangeEdit();
-  String deleteDraftChange();
+  String deleteChange();
   String deleteDraftRevision();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties
index 5b4f18f..dd4760d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties
@@ -34,5 +34,5 @@
 deleteChangeEdit = Delete Change Edit?\n\
   \n\
   All changes made in the edit revision will be lost.
-deleteDraftChange = Delete Draft Change?
+deleteChange = Delete Change?
 deleteDraftRevision = Delete Draft Revision?
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.java
index 62c3636..2a8dacf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.java
@@ -30,4 +30,5 @@
   String submittedTogether(int count);
   String submittedTogether(String count);
   String editPatchSet(int patchSet);
+  String failedToLoadFileList(String error);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.properties
index 6461899..743945d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.properties
@@ -6,3 +6,4 @@
 sameTopic = Same Topic ({0})
 submittedTogether = Submitted Together ({0})
 editPatchSet = edit:{0}
+failedToLoadFileList = Failed to load file list: {0}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
index b2e9f28..fa3855e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.client.change;
 
 import com.google.gerrit.client.AvatarImage;
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.ErrorDialog;
 import com.google.gerrit.client.FormatUtil;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.GerritUiExtensionPoint;
+import com.google.gerrit.client.NotFoundScreen;
 import com.google.gerrit.client.api.ChangeGlue;
 import com.google.gerrit.client.api.ExtensionPanel;
 import com.google.gerrit.client.changes.ChangeApi;
@@ -107,8 +109,13 @@
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 
 public class ChangeScreen extends Screen {
+  private static final Logger logger =
+      Logger.getLogger(ChangeScreen.class.getName());
+
   interface Binder extends UiBinder<HTMLPanel, ChangeScreen> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
 
@@ -141,7 +148,7 @@
   }
 
   private final Change.Id changeId;
-  private String base;
+  private DiffObject base;
   private String revision;
   private ChangeInfo changeInfo;
   private boolean hasDraftComments;
@@ -165,6 +172,8 @@
   @UiField ToggleButton star;
   @UiField Anchor permalink;
 
+  @UiField Assignee assignee;
+  @UiField Element assigneeRow;
   @UiField Element ccText;
   @UiField Reviewers reviewers;
   @UiField Hashtags hashtags;
@@ -199,6 +208,7 @@
   @UiField FileTable files;
   @UiField ListBox diffBase;
   @UiField History history;
+  @UiField SimplePanel historyExtensionRight;
 
   @UiField Button includedIn;
   @UiField Button patchSets;
@@ -219,6 +229,8 @@
   @UiField Button renameFile;
   @UiField Button expandAll;
   @UiField Button collapseAll;
+  @UiField Button hideTaggedComments;
+  @UiField Button showTaggedComments;
   @UiField QuickApprove quickApprove;
 
   private ReplyAction replyAction;
@@ -229,10 +241,10 @@
   private DeleteFileAction deleteFileAction;
   private RenameFileAction renameFileAction;
 
-  public ChangeScreen(Change.Id changeId, String base, String revision,
+  public ChangeScreen(Change.Id changeId, DiffObject base, String revision,
       boolean openReplyBox, FileTable.Mode mode) {
     this.changeId = changeId;
-    this.base = normalize(base);
+    this.base = base;
     this.revision = normalize(revision);
     this.openReplyBox = openReplyBox;
     this.fileTableMode = mode;
@@ -282,14 +294,26 @@
             info.init();
             addExtensionPoints(info, initCurrentRevision(info));
 
-            RevisionInfo rev = info.revision(revision);
+            final RevisionInfo rev = info.revision(revision);
             CallbackGroup group = new CallbackGroup();
             loadCommit(rev, group);
 
             group.addListener(new GerritCallback<Void>() {
               @Override
               public void onSuccess(Void result) {
+                if (base.isBase() && rev.isMerge()) {
+                  base = DiffObject.parse(info.legacyId(),
+                      Gerrit.getUserPreferences()
+                          .defaultBaseForMerges().getBase());
+                }
                 loadConfigInfo(info, base);
+                JsArray<MessageInfo> mAr = info.messages();
+                for (int i = 0; i < mAr.length(); i++) {
+                  if (mAr.get(i).tag() != null) {
+                    hideTaggedComments.setVisible(true);
+                    break;
+                  }
+                }
               }
             });
             group.done();
@@ -352,6 +376,9 @@
     addExtensionPoint(
         GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK,
         commitExtension, change, rev);
+    addExtensionPoint(
+        GerritUiExtensionPoint.CHANGE_SCREEN_HISTORY_RIGHT_OF_BUTTONS,
+        historyExtensionRight, change, rev);
   }
 
   private void addExtensionPoint(GerritUiExtensionPoint extensionPoint,
@@ -468,15 +495,13 @@
   }
 
   private void initChangeAction(ChangeInfo info) {
-    if (info.status() == Status.DRAFT) {
-      NativeMap<ActionInfo> actions = info.hasActions()
-          ? info.actions()
-          : NativeMap.<ActionInfo> create();
-      actions.copyKeysIntoChildren("id");
-      if (actions.containsKey("/")) {
-        deleteChange.setVisible(true);
-        deleteChange.setTitle(actions.get("/").title());
-      }
+    NativeMap<ActionInfo> actions = info.hasActions()
+        ? info.actions()
+        : NativeMap.create();
+    actions.copyKeysIntoChildren("id");
+    if (actions.containsKey("/")) {
+      deleteChange.setVisible(true);
+      deleteChange.setTitle(actions.get("/").title());
     }
   }
 
@@ -569,35 +594,40 @@
   }
 
   private void initEditMode(ChangeInfo info, String revision) {
-    if (Gerrit.isSignedIn() && info.status().isOpen()) {
+    if (Gerrit.isSignedIn()) {
       RevisionInfo rev = info.revision(revision);
-      if (isEditModeEnabled(info, rev)) {
-        editMode.setVisible(fileTableMode == FileTable.Mode.REVIEW);
-        addFile.setVisible(!editMode.isVisible());
-        deleteFile.setVisible(!editMode.isVisible());
-        renameFile.setVisible(!editMode.isVisible());
-        reviewMode.setVisible(!editMode.isVisible());
-        addFileAction = new AddFileAction(
-            changeId, info.revision(revision),
-            style, addFile, files);
-        deleteFileAction = new DeleteFileAction(
-            changeId, info.revision(revision),
-            style, addFile);
-        renameFileAction = new RenameFileAction(
-            changeId, info.revision(revision),
-            style, addFile);
-      } else {
-        editMode.setVisible(false);
-        addFile.setVisible(false);
-        reviewMode.setVisible(false);
-      }
-
-      if (rev.isEdit()) {
-        if (info.hasEditBasedOnCurrentPatchSet()) {
-          publishEdit.setVisible(true);
+      if (info.status().isOpen()) {
+        if (isEditModeEnabled(info, rev)) {
+          editMode.setVisible(fileTableMode == FileTable.Mode.REVIEW);
+          addFile.setVisible(!editMode.isVisible());
+          deleteFile.setVisible(!editMode.isVisible());
+          renameFile.setVisible(!editMode.isVisible());
+          reviewMode.setVisible(!editMode.isVisible());
+          addFileAction = new AddFileAction(
+              changeId, info.revision(revision),
+              style, addFile, files);
+          deleteFileAction = new DeleteFileAction(
+              changeId, info.revision(revision),
+              style, addFile);
+          renameFileAction = new RenameFileAction(
+              changeId, info.revision(revision),
+              style, addFile);
         } else {
-          rebaseEdit.setVisible(true);
+          editMode.setVisible(false);
+          addFile.setVisible(false);
+          reviewMode.setVisible(false);
         }
+
+        if (rev.isEdit()) {
+          if (info.hasEditBasedOnCurrentPatchSet()) {
+            publishEdit.setVisible(true);
+          } else {
+            rebaseEdit.setVisible(true);
+          }
+          deleteEdit.setVisible(true);
+        }
+      } else if (rev.isEdit()) {
+        deleteEdit.setStyleName(style.highlight());
         deleteEdit.setVisible(true);
       }
     }
@@ -616,37 +646,39 @@
 
   @UiHandler("publishEdit")
   void onPublishEdit(@SuppressWarnings("unused") ClickEvent e) {
-    EditActions.publishEdit(changeId);
+    EditActions.publishEdit(changeId, publishEdit, rebaseEdit, deleteEdit);
   }
 
   @UiHandler("rebaseEdit")
   void onRebaseEdit(@SuppressWarnings("unused") ClickEvent e) {
-    EditActions.rebaseEdit(changeId);
+    EditActions.rebaseEdit(changeId, publishEdit, rebaseEdit, deleteEdit);
   }
 
   @UiHandler("deleteEdit")
   void onDeleteEdit(@SuppressWarnings("unused") ClickEvent e) {
     if (Window.confirm(Resources.C.deleteChangeEdit())) {
-      EditActions.deleteEdit(changeId);
+      EditActions.deleteEdit(changeId, publishEdit, rebaseEdit, deleteEdit);
     }
   }
 
   @UiHandler("publish")
   void onPublish(@SuppressWarnings("unused") ClickEvent e) {
-    DraftActions.publish(changeId, revision);
+    DraftActions.publish(changeId, revision, publish, deleteRevision,
+        deleteChange);
   }
 
   @UiHandler("deleteRevision")
   void onDeleteRevision(@SuppressWarnings("unused") ClickEvent e) {
     if (Window.confirm(Resources.C.deleteDraftRevision())) {
-      DraftActions.delete(changeId, revision);
+      DraftActions.delete(changeId, revision, publish, deleteRevision,
+          deleteChange);
     }
   }
 
   @UiHandler("deleteChange")
   void onDeleteChange(@SuppressWarnings("unused") ClickEvent e) {
-    if (Window.confirm(Resources.C.deleteDraftChange())) {
-      DraftActions.delete(changeId);
+    if (Window.confirm(Resources.C.deleteChange())) {
+      DraftActions.delete(changeId, publish, deleteRevision, deleteChange);
     }
   }
 
@@ -884,7 +916,31 @@
     int idx = diffBase.getSelectedIndex();
     if (0 <= idx) {
       String n = diffBase.getValue(idx);
-      loadConfigInfo(changeInfo, !n.isEmpty() ? n : null);
+      loadConfigInfo(changeInfo, DiffObject.parse(changeInfo.legacyId(), n));
+    }
+  }
+
+  @UiHandler("showTaggedComments")
+  void onShowTaggedComments(@SuppressWarnings("unused") ClickEvent e) {
+    showTaggedComments.setVisible(false);
+    hideTaggedComments.setVisible(true);
+    int n = history.getWidgetCount();
+    for (int i = 0; i < n; i++) {
+      Message m = ((Message) history.getWidget(i));
+      m.setVisible(true);
+    }
+  }
+
+  @UiHandler("hideTaggedComments")
+  void onHideTaggedComments(@SuppressWarnings("unused") ClickEvent e) {
+    hideTaggedComments.setVisible(false);
+    showTaggedComments.setVisible(true);
+    int n = history.getWidgetCount();
+    for (int i = 0; i < n; i++) {
+      Message m = ((Message) history.getWidget(i));
+      if (m.getMessageInfo().tag() != null) {
+        m.setVisible(false);
+      }
     }
   }
 
@@ -913,13 +969,20 @@
     int idx = diffBase.getSelectedIndex();
     if (0 <= idx) {
       String n = diffBase.getValue(idx);
-      loadConfigInfo(changeInfo, !n.isEmpty() ? n : null);
+      loadConfigInfo(changeInfo, DiffObject.parse(changeInfo.legacyId(), n));
     }
   }
 
-  private void loadConfigInfo(final ChangeInfo info, String base) {
-    RevisionInfo rev = info.revision(revision);
-    RevisionInfo b = resolveRevisionOrPatchSetId(info, base, null);
+  private void loadConfigInfo(final ChangeInfo info, DiffObject base) {
+    final RevisionInfo rev = info.revision(revision);
+    if (base.isAutoMerge() && !initCurrentRevision(info).isMerge()) {
+      Gerrit.display(getToken(), new NotFoundScreen());
+    }
+
+    updateToken(info, base, rev);
+
+    RevisionInfo baseRev =
+        resolveRevisionOrPatchSetId(info, base.asString(), null);
 
     CallbackGroup group = new CallbackGroup();
     Timestamp lastReply = myLastReply(info);
@@ -929,20 +992,36 @@
       RevisionInfo p = RevisionInfo.findEditParentRevision(
           info.revisions().values());
       List<NativeMap<JsArray<CommentInfo>>> comments = loadComments(p, group);
-      loadFileList(b, rev, lastReply, group, comments, null);
+      loadFileList(base, baseRev, rev, lastReply, group, comments, null);
     } else {
-      loadDiff(b, rev, lastReply, group);
+      loadDiff(base, baseRev, rev, lastReply, group);
     }
+    group.addListener(new AsyncCallback<Void>() {
+      @Override
+      public void onSuccess(Void result) {
+        loadConfigInfo(info, rev);
+      }
 
+      @Override
+      public void onFailure(Throwable caught) {
+        logger.log(Level.SEVERE,
+            "Loading file list and inline comments failed: "
+                + caught.getMessage());
+        loadConfigInfo(info, rev);
+      }
+    });
+    group.done();
+  }
+
+  private void loadConfigInfo(final ChangeInfo info, RevisionInfo rev) {
     if (loaded) {
-      group.done();
       return;
     }
 
     RevisionInfoCache.add(changeId, rev);
     ConfigInfoCache.add(info);
     ConfigInfoCache.get(info.projectNameKey(),
-      group.addFinal(new ScreenLoadCallback<ConfigInfoCache.Entry>(this) {
+      new ScreenLoadCallback<ConfigInfoCache.Entry>(this) {
         @Override
         protected void preDisplay(Entry result) {
           loaded = true;
@@ -951,7 +1030,22 @@
           renderChangeInfo(info);
           loadRevisionInfo();
         }
-      }));
+      });
+  }
+
+  private void updateToken(ChangeInfo info, DiffObject base, RevisionInfo rev) {
+    StringBuilder token = new StringBuilder("/c/")
+        .append(info._number())
+        .append("/");
+    if (base.asString() != null) {
+      token.append(base.asString())
+          .append("..");
+    }
+    if (base.asString() != null
+        || !rev.name().equals(info.currentRevision())) {
+      token.append(rev._number());
+    }
+    setToken(token.toString());
   }
 
   static Timestamp myLastReply(ChangeInfo info) {
@@ -967,11 +1061,11 @@
     return null;
   }
 
-  private void loadDiff(RevisionInfo base, RevisionInfo rev,
+  private void loadDiff(DiffObject base, RevisionInfo baseRev, RevisionInfo rev,
       Timestamp myLastReply, CallbackGroup group) {
     List<NativeMap<JsArray<CommentInfo>>> comments = loadComments(rev, group);
     List<NativeMap<JsArray<CommentInfo>>> drafts = loadDrafts(rev, group);
-    loadFileList(base, rev, myLastReply, group, comments, drafts);
+    loadFileList(base, baseRev, rev, myLastReply, group, comments, drafts);
 
     if (Gerrit.isSignedIn() && fileTableMode == FileTable.Mode.REVIEW) {
       ChangeApi.revision(changeId.get(), rev.name())
@@ -990,19 +1084,19 @@
     }
   }
 
-  private void loadFileList(final RevisionInfo base, final RevisionInfo rev,
-      final Timestamp myLastReply, CallbackGroup group,
+  private void loadFileList(final DiffObject base, final RevisionInfo baseRev,
+      final RevisionInfo rev, final Timestamp myLastReply, CallbackGroup group,
       final List<NativeMap<JsArray<CommentInfo>>> comments,
       final List<NativeMap<JsArray<CommentInfo>>> drafts) {
     DiffApi.list(changeId.get(),
         rev.name(),
-        base,
+        baseRev,
         group.add(
             new AsyncCallback<NativeMap<FileInfo>>() {
               @Override
               public void onSuccess(NativeMap<FileInfo> m) {
                 files.set(
-                    base != null ? new PatchSet.Id(changeId, base._number()) : null,
+                    base,
                     new PatchSet.Id(changeId, rev._number()),
                     style, reply, fileTableMode, edit != null);
                 files.setValue(m, myLastReply,
@@ -1012,6 +1106,7 @@
 
               @Override
               public void onFailure(Throwable caught) {
+                files.showError(caught);
               }
             }));
   }
@@ -1224,6 +1319,11 @@
     commit.set(commentLinkProcessor, info, revision);
     related.set(info, revision);
     reviewers.set(info);
+    if (Gerrit.info().change().showAssignee()) {
+      assignee.set(info);
+    } else {
+      setVisible(assigneeRow, false);
+    }
     if (Gerrit.isNoteDbEnabled()) {
       hashtags.set(info, revision);
     } else {
@@ -1398,12 +1498,12 @@
       RevisionInfo r = list.get(i);
       diffBase.addItem(
         r.id() + ": " + r.name().substring(0, 6),
-        r.name());
+        r.id());
       if (r.name().equals(revision)) {
         SelectElement.as(diffBase.getElement()).getOptions()
             .getItem(diffBase.getItemCount() - 1).setDisabled(true);
       }
-      if (base != null && base.equals(String.valueOf(r._number()))) {
+      if (base.isPatchSet() && base.asPatchSetId().get() == r._number()) {
         selectedIdx = diffBase.getItemCount() - 1;
       }
     }
@@ -1411,15 +1511,15 @@
     RevisionInfo rev = info.revisions().get(revision);
     JsArray<CommitInfo> parents = rev.commit().parents();
     if (parents.length() > 1) {
-      diffBase.addItem(Util.C.autoMerge(), "");
+      diffBase.addItem(Util.C.autoMerge(), DiffObject.AUTO_MERGE);
       for (int i = 0; i < parents.length(); i++) {
         int parentNum = i + 1;
         diffBase.addItem(Util.M.diffBaseParent(parentNum),
             String.valueOf(-parentNum));
       }
-      int parentNum = toParentNum(base);
-      if (parentNum > 0) {
-        selectedIdx = list.length() + parentNum;
+
+      if (base.isParent()) {
+        selectedIdx = list.length() + base.getParentNum();
       }
     } else {
       diffBase.addItem(Util.C.baseDiffItem(), "");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
index a0d5405..da18317 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
@@ -355,6 +355,11 @@
       padding-top: 5px;
     }
 
+    .historyExtension {
+      display: inline-block;
+      float: right;
+    }
+
     .pushCertStatus {
       padding-left: 5px;
     }
@@ -458,6 +463,12 @@
                 </g:FlowPanel>
               </td>
             </tr>
+            <tr ui:field='assigneeRow'>
+              <th><ui:msg>Assignee</ui:msg></th>
+              <td>
+                <c:Assignee ui:field='assignee'/>
+              </td>
+            </tr>
             <tr>
               <th><ui:msg>Reviewers</ui:msg></th>
               <td>
@@ -601,6 +612,21 @@
           <ui:attribute name='title'/>
           <div><ui:msg>Collapse All</ui:msg></div>
         </g:Button>
+        <g:Button ui:field='hideTaggedComments'
+            styleName=''
+            visible='false'
+            title='Hide tagged comments'>
+          <ui:attribute name='title'/>
+          <div><ui:msg>Hide tagged comments</ui:msg></div>
+        </g:Button>
+        <g:Button ui:field='showTaggedComments'
+            styleName=''
+            visible='false'
+            title='Show tagged comments'>
+          <ui:attribute name='title'/>
+          <div><ui:msg>Show tagged comments</ui:msg></div>
+        </g:Button>
+        <g:SimplePanel ui:field='historyExtensionRight' styleName='{style.historyExtension}'/>
       </div>
     </div>
     <c:History ui:field='history'/>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
index 42963f7..9ec1356 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.client.ui.InlineHyperlink;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
@@ -174,7 +175,7 @@
     // no avatar plugin is installed
     if (change.owner().hasAvatarInfo()) {
       AvatarImage avatar;
-      if (change.owner().email().equals(person.email())) {
+      if (sameEmail(change.owner(), person)) {
         avatar = new AvatarImage(change.owner());
       } else {
         avatar = new AvatarImage(
@@ -202,4 +203,11 @@
       return "";
     }
   }
+
+  private static boolean sameEmail(
+      @Nullable AccountInfo p1, @Nullable GitPerson p2) {
+    return p1 != null && p2 != null &&
+        p1.email() != null && p2.email() != null &&
+        p1.email().equals(p2.email());
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DraftActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DraftActions.java
index 634190a2..6787576 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DraftActions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DraftActions.java
@@ -21,23 +21,25 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.user.client.rpc.AsyncCallback;
+import com.google.gwt.user.client.ui.Button;
 
 public class DraftActions {
 
-  static void publish(Change.Id id, String revision) {
-    ChangeApi.publish(id.get(), revision, cs(id));
+  static void publish(Change.Id id, String revision, Button... draftButtons) {
+    ChangeApi.publish(id.get(), revision, cs(id, draftButtons));
   }
 
-  static void delete(Change.Id id, String revision) {
-    ChangeApi.deleteRevision(id.get(), revision, cs(id));
+  static void delete(Change.Id id, String revision, Button... draftButtons) {
+    ChangeApi.deleteRevision(id.get(), revision, cs(id, draftButtons));
   }
 
-  static void delete(Change.Id id) {
-    ChangeApi.deleteChange(id.get(), mine());
+  static void delete(Change.Id id, Button... draftButtons) {
+    ChangeApi.deleteChange(id.get(), mine(draftButtons));
   }
 
   public static GerritCallback<JavaScriptObject> cs(
-      final Change.Id id) {
+      final Change.Id id, final Button... draftButtons) {
+    setEnabled(false, draftButtons);
     return new GerritCallback<JavaScriptObject>() {
       @Override
       public void onSuccess(JavaScriptObject result) {
@@ -46,6 +48,7 @@
 
       @Override
       public void onFailure(Throwable err) {
+        setEnabled(true, draftButtons);
         if (SubmitFailureDialog.isConflict(err)) {
           new SubmitFailureDialog(err.getMessage()).center();
           Gerrit.display(PageLinks.toChange(id));
@@ -56,7 +59,9 @@
     };
   }
 
-  private static AsyncCallback<JavaScriptObject> mine() {
+  private static AsyncCallback<JavaScriptObject> mine(
+      final Button... draftButtons) {
+    setEnabled(false, draftButtons);
     return new GerritCallback<JavaScriptObject>() {
       @Override
       public void onSuccess(JavaScriptObject result) {
@@ -65,6 +70,7 @@
 
       @Override
       public void onFailure(Throwable err) {
+        setEnabled(true, draftButtons);
         if (SubmitFailureDialog.isConflict(err)) {
           new SubmitFailureDialog(err.getMessage()).center();
           Gerrit.display(PageLinks.MINE);
@@ -74,4 +80,12 @@
       }
     };
   }
+
+  private static void setEnabled(boolean enabled, Button... draftButtons) {
+    if (draftButtons != null) {
+      for (Button b : draftButtons) {
+        b.setEnabled(enabled);
+      }
+    }
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java
index d11cf7e..97abddb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java
@@ -20,23 +20,25 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.user.client.ui.Button;
 
 public class EditActions {
 
-  static void deleteEdit(Change.Id id) {
-    ChangeApi.deleteEdit(id.get(), cs(id));
+  static void deleteEdit(Change.Id id, Button... editButtons) {
+    ChangeApi.deleteEdit(id.get(), cs(id, editButtons));
   }
 
-  static void publishEdit(Change.Id id) {
-    ChangeApi.publishEdit(id.get(), cs(id));
+  static void publishEdit(Change.Id id, Button... editButtons) {
+    ChangeApi.publishEdit(id.get(), cs(id, editButtons));
   }
 
-  static void rebaseEdit(Change.Id id) {
-    ChangeApi.rebaseEdit(id.get(), cs(id));
+  static void rebaseEdit(Change.Id id, Button... editButtons) {
+    ChangeApi.rebaseEdit(id.get(), cs(id, editButtons));
   }
 
   public static GerritCallback<JavaScriptObject> cs(
-      final Change.Id id) {
+      final Change.Id id, final Button... editButtons) {
+    setEnabled(false, editButtons);
     return new GerritCallback<JavaScriptObject>() {
       @Override
       public void onSuccess(JavaScriptObject result) {
@@ -45,6 +47,7 @@
 
       @Override
       public void onFailure(Throwable err) {
+        setEnabled(true, editButtons);
         if (SubmitFailureDialog.isConflict(err)) {
           new SubmitFailureDialog(err.getMessage()).center();
           Gerrit.display(PageLinks.toChange(id));
@@ -54,4 +57,12 @@
       }
     };
   }
+
+  private static void setEnabled(boolean enabled, Button... editButtons) {
+    if (editButtons != null) {
+      for (Button b : editButtons) {
+        b.setEnabled(enabled);
+      }
+    }
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
index f0a7ce3..a95270b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.client.FormatUtil.formatBytes;
 import static com.google.gerrit.client.FormatUtil.formatPercentage;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.VoidResult;
@@ -60,6 +61,7 @@
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
 import com.google.gwt.user.client.ui.ImageResourceRenderer;
+import com.google.gwt.user.client.ui.Label;
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwt.user.client.ui.impl.HyperlinkImpl;
 import com.google.gwtexpui.globalkey.client.KeyCommand;
@@ -94,6 +96,7 @@
     String inserted();
     String deleted();
     String restoreDelete();
+    String error();
   }
 
   public enum Mode {
@@ -180,7 +183,7 @@
     return null;
   }
 
-  private PatchSet.Id base;
+  private DiffObject base;
   private PatchSet.Id curr;
   private MyTable table;
   private boolean register;
@@ -197,7 +200,7 @@
     R.css().ensureInjected();
   }
 
-  public void set(PatchSet.Id base, PatchSet.Id curr, ChangeScreen.Style style,
+  public void set(DiffObject base, PatchSet.Id curr, ChangeScreen.Style style,
       Widget replyButton, Mode mode, boolean editExists) {
     this.base = base;
     this.curr = curr;
@@ -222,6 +225,13 @@
     }
   }
 
+  void showError(Throwable t) {
+    clear();
+    Label l = new Label(Resources.M.failedToLoadFileList(t.getMessage()));
+    add(l);
+    l.setStyleName(R.css().error());
+  }
+
   void markReviewed(JsArrayString reviewed) {
     if (table != null) {
       table.markReviewed(reviewed);
@@ -258,11 +268,18 @@
     if (table != null) {
       String self = Gerrit.selfRedirect(null);
       for (FileInfo info : Natives.asList(table.list)) {
-        Window.open(self + "#" + url(info), "_blank", null);
+        if (canOpen(info.path())) {
+          Window.open(self + "#" + url(info), "_blank", null);
+        }
       }
     }
   }
 
+  private boolean canOpen(String path) {
+    return mode != Mode.EDIT || !Patch.isMagic(path)
+        || Patch.COMMIT_MSG.equals(path);
+  }
+
   private void setTable(MyTable table) {
     clear();
     add(table);
@@ -324,7 +341,7 @@
       });
 
       setSavePointerId(
-          (base != null ? base.toString() + ".." : "")
+          (!base.isBase() ? base.asString() + ".." : "")
           + curr.toString());
     }
 
@@ -420,7 +437,10 @@
     @Override
     protected void onOpenRow(int row) {
       if (1 <= row && row <= list.length()) {
-        Gerrit.display(url(list.get(row - 1)));
+        FileInfo info = list.get(row - 1);
+        if (canOpen(info.path())) {
+          Gerrit.display(url(info));
+        }
       }
     }
 
@@ -443,7 +463,10 @@
 
       @Override
       public void onKeyPress(KeyPressEvent event) {
-        Gerrit.display(url(list.get(index)));
+        FileInfo info = list.get(index);
+        if (canOpen(info.path())) {
+          Gerrit.display(url(info));
+        }
       }
     }
   }
@@ -529,7 +552,7 @@
       bytesDeleted = 0;
       for (int i = 0; i < list.length(); i++) {
         FileInfo info = list.get(i);
-        if (!Patch.COMMIT_MSG.equals(info.path())) {
+        if (!Patch.isMagic(info.path())) {
           if (!info.binary()) {
             hasNonBinaryFile = true;
             inserted += info.linesInserted();
@@ -619,7 +642,7 @@
     private void columnDeleteRestore(SafeHtmlBuilder sb, FileInfo info) {
       sb.openTd().setStyleName(R.css().restoreDelete());
       if (hasUser) {
-        if (!Patch.COMMIT_MSG.equals(info.path())) {
+        if (!Patch.isMagic(info.path())) {
           boolean editable = isEditable(info);
           sb.openDiv()
             .openElement("button")
@@ -650,7 +673,7 @@
 
     private void columnStatus(SafeHtmlBuilder sb, FileInfo info) {
       sb.openTd().setStyleName(R.css().status());
-      if (!Patch.COMMIT_MSG.equals(info.path())
+      if (!Patch.isMagic(info.path())
           && info.status() != null
           && !ChangeType.MODIFIED.matches(info.status())) {
         sb.append(info.status());
@@ -659,20 +682,43 @@
     }
 
     private void columnPath(SafeHtmlBuilder sb, FileInfo info) {
-      sb.openTd()
-        .setStyleName(R.css().pathColumn())
-        .openAnchor();
-
       String path = info.path();
+
+      sb.openTd()
+        .setStyleName(R.css().pathColumn());
+
+      if (!canOpen(path)) {
+        sb.openDiv();
+        appendPath(path);
+        sb.closeDiv();
+        sb.closeTd();
+        return;
+      }
+
+      sb.openAnchor();
+
       if (mode == Mode.EDIT && !isEditable(info)) {
         sb.setAttribute("onclick", RESTORE + "(event," + info._row() + ")");
       } else {
         sb.setAttribute("href", "#" + url(info))
           .setAttribute("onclick", OPEN + "(event," + info._row() + ")");
       }
+      appendPath(path);
+      sb.closeAnchor();
+      if (info.oldPath() != null) {
+        sb.br();
+        sb.openSpan().setStyleName(R.css().renameCopySource())
+          .append(info.oldPath())
+          .closeSpan();
+      }
+      sb.closeTd();
+    }
 
+    private void appendPath(String path) {
       if (Patch.COMMIT_MSG.equals(path)) {
         sb.append(Util.C.commitMessage());
+      } else if (Patch.MERGE_LIST.equals(path)) {
+        sb.append(Util.C.mergeList());
       } else if (Gerrit.getUserPreferences().muteCommonPathPrefixes()) {
         int commonPrefixLen = commonPrefix(path);
         if (commonPrefixLen > 0) {
@@ -685,15 +731,6 @@
       } else {
         sb.append(path);
       }
-
-      sb.closeAnchor();
-      if (info.oldPath() != null) {
-        sb.br();
-        sb.openSpan().setStyleName(R.css().renameCopySource())
-          .append(info.oldPath())
-          .closeSpan();
-      }
-      sb.closeTd();
     }
 
     private int commonPrefix(String path) {
@@ -753,9 +790,9 @@
       for (CommentInfo c : Natives.asList(list)) {
         if (c.side() == Side.REVISION) {
           result.push(c);
-        } else if (base == null && !c.hasParent()) {
+        } else if (base.isBaseOrAutoMerge() && !c.hasParent()) {
           result.push(c);
-        } else if (base != null && c.parent() == -base.get()) {
+        } else if (base.isParent() && c.parent() == base.getParentNum()) {
           result.push(c);
         }
       }
@@ -775,7 +812,7 @@
 
     private void columnDelta1(SafeHtmlBuilder sb, FileInfo info) {
       sb.openTd().setStyleName(R.css().deltaColumn1());
-      if (!Patch.COMMIT_MSG.equals(info.path()) && !info.binary()) {
+      if (!Patch.isMagic(info.path()) && !info.binary()) {
         if (showChangeSizeBars) {
           sb.append(info.linesInserted() + info.linesDeleted());
         } else if (!ChangeType.DELETED.matches(info.status())) {
@@ -804,7 +841,7 @@
     private void columnDelta2(SafeHtmlBuilder sb, FileInfo info) {
       sb.openTd().setStyleName(R.css().deltaColumn2());
       if (showChangeSizeBars
-          && !Patch.COMMIT_MSG.equals(info.path()) && !info.binary()
+          && !Patch.isMagic(info.path()) && !info.binary()
           && (info.linesInserted() != 0 || info.linesDeleted() != 0)) {
         int w = 80;
         int t = inserted + deleted;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java
index 6c27ed9..c8735b7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java
@@ -176,6 +176,10 @@
     if (l != null) {
       comments.add(new FileComments(clp, ps, Util.C.commitMessage(), l));
     }
+    l = m.remove(Patch.MERGE_LIST);
+    if (l != null) {
+      comments.add(new FileComments(clp, ps, Util.C.mergeList(), l));
+    }
     for (Map.Entry<String, List<CommentInfo>> e : m.entrySet()) {
       comments.add(new FileComments(clp, ps, e.getKey(), e.getValue()));
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
index cc5c9b7..2d868da 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
@@ -215,10 +215,11 @@
         EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT),
         new TabChangeListCallback(Tab.CHERRY_PICKS, info.project(), revision));
 
-    // TODO(sbeller): show only on latest revision
-    ChangeApi.change(info.legacyId().get()).view("submitted_together")
-        .get(new TabChangeListCallback(Tab.SUBMITTED_TOGETHER,
-            info.project(), revision));
+    if (info.currentRevision().equals(revision)) {
+      ChangeApi.change(info.legacyId().get()).view("submitted_together")
+          .get(new TabChangeListCallback(Tab.SUBMITTED_TOGETHER,
+              info.project(), revision));
+    }
 
     if (!Gerrit.info().change().isSubmitWholeTopicEnabled()
         && info.topic() != null && !"".equals(info.topic())) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java
index 791effc..846ad53 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java
@@ -178,7 +178,7 @@
       rows = new ArrayList<>(changes.length());
       connectedPos = changes.length() - 1;
       connected = showIndirectAncestors
-          ? new HashSet<String>(Math.max(changes.length() * 4 / 3, 16))
+          ? new HashSet<>(Math.max(changes.length() * 4 / 3, 16))
           : null;
     }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
index e29048a..0eea695 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
@@ -304,6 +304,9 @@
     for (String id : names) {
       JsArrayString p = permitted.get(id);
       if (p != null) {
+        if (!all.containsKey(id)) {
+          continue;
+        }
         Set<Short> a = new TreeSet<>();
         for (int i = 0; i < p.length(); i++) {
           a.add(LabelInfo.parseValue(p.get(i)));
@@ -422,12 +425,17 @@
       comments.add(new FileComments(clp, psId,
           Util.C.commitMessage(), copyPath(Patch.COMMIT_MSG, l)));
     }
+    l = m.get(Patch.MERGE_LIST);
+    if (l != null) {
+      comments.add(new FileComments(clp, psId, Util.C.commitMessage(),
+          copyPath(Patch.MERGE_LIST, l)));
+    }
 
     List<String> paths = new ArrayList<>(m.keySet());
     Collections.sort(paths);
 
     for (String path : paths) {
-      if (!path.equals(Patch.COMMIT_MSG)) {
+      if (!Patch.isMagic(path)) {
         comments.add(new FileComments(clp, psId,
             path, copyPath(path, m.get(path))));
       }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
index a852fa0..6f518b1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
@@ -16,27 +16,28 @@
 
 import com.google.gerrit.client.admin.Util;
 import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.groups.GroupBaseInfo;
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.GroupBaseInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.AccountSuggestOracle;
-import com.google.gerrit.client.ui.SuggestAfterTypingNCharsOracle;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
+import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
 
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
 /** REST API based suggestion Oracle for reviewers. */
-public class ReviewerSuggestOracle extends SuggestAfterTypingNCharsOracle {
+public class ReviewerSuggestOracle extends HighlightSuggestOracle {
   private Change.Id changeId;
 
   @Override
-  protected void _onRequestSuggestions(final Request req, final Callback cb) {
-    ChangeApi.suggestReviewers(changeId.get(), req.getQuery(), req.getLimit())
+  protected void onRequestSuggestions(final Request req, final Callback cb) {
+    ChangeApi
+        .suggestReviewers(changeId.get(), req.getQuery(), req.getLimit(), false)
         .get(new GerritCallback<JsArray<SuggestReviewerInfo>>() {
           @Override
           public void onSuccess(JsArray<SuggestReviewerInfo> result) {
@@ -55,11 +56,16 @@
         });
   }
 
+  @Override
+  public void requestDefaultSuggestions(final Request req, final Callback cb) {
+    requestSuggestions(req, cb);
+  }
+
   public void setChange(Change.Id changeId) {
     this.changeId = changeId;
   }
 
-  private static class RestReviewerSuggestion implements Suggestion {
+  public static class RestReviewerSuggestion implements Suggestion {
     private final String displayString;
     private final String replacementString;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
index b69d1c0..e0c252c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
@@ -81,6 +81,7 @@
   Reviewers() {
     reviewerSuggestOracle = new ReviewerSuggestOracle();
     suggestBox = new RemoteSuggestBox(reviewerSuggestOracle);
+    suggestBox.enableDefaultSuggestions();
     suggestBox.setVisibleLength(55);
     suggestBox.setHintText(Util.C.approvalTableAddReviewerHint());
     suggestBox.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
@@ -123,6 +124,7 @@
     UIObject.setVisible(form, true);
     UIObject.setVisible(error, false);
     addReviewerIcon.setVisible(false);
+    suggestBox.setServeSuggestionsOnOracle(true);
     suggestBox.setFocus(true);
   }
 
@@ -143,6 +145,7 @@
     UIObject.setVisible(form, false);
     suggestBox.setFocus(false);
     suggestBox.setText("");
+    suggestBox.setServeSuggestionsOnOracle(false);
   }
 
   private void addReviewer(final String reviewer, boolean confirmed) {
@@ -198,7 +201,7 @@
         });
   }
 
-  private void updateReviewerList() {
+  void updateReviewerList() {
     ChangeApi.detail(changeId.get(),
         new GerritCallback<ChangeInfo>() {
           @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
index 025668f..a063b6c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
@@ -100,6 +100,7 @@
 
       input.setText(text.getText());
       input.setFocus(true);
+      input.selectAll();
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/file_table.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/file_table.css
index bde9755..6f514df 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/file_table.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/file_table.css
@@ -109,3 +109,7 @@
   white-space: nowrap;
 }
 
+.error {
+  color: #D33D3D;
+  font-weight: bold;
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
index b181341..a008149 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client.changes;
 
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.client.info.ChangeInfo;
 import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
 import com.google.gerrit.client.info.ChangeInfo.EditInfo;
@@ -34,16 +35,17 @@
 public class ChangeApi {
   /** Abandon the change, ending its review. */
   public static void abandon(int id, String msg, AsyncCallback<ChangeInfo> cb) {
-    Input input = Input.create();
+    MessageInput input = MessageInput.create();
     input.message(emptyToNull(msg));
     call(id, "abandon").post(input, cb);
   }
 
-  /** Create a new change.
+  /**
+   * Create a new change.
    *
-   * The new change is created as DRAFT unless the draft workflow is disabled
-   * by `change.allowDrafts = false` in the configuration, in which case the
-   * new change is created as NEW.
+   * The new change is created as DRAFT unless the draft workflow is disabled by
+   * `change.allowDrafts = false` in the configuration, in which case the new
+   * change is created as NEW.
    *
    */
   public static void createChange(String project, String branch, String topic,
@@ -64,14 +66,14 @@
 
   /** Restore a previously abandoned change to be open again. */
   public static void restore(int id, String msg, AsyncCallback<ChangeInfo> cb) {
-    Input input = Input.create();
+    MessageInput input = MessageInput.create();
     input.message(emptyToNull(msg));
     call(id, "restore").post(input, cb);
   }
 
   /** Create a new change that reverts the delta caused by this change. */
   public static void revert(int id, String msg, AsyncCallback<ChangeInfo> cb) {
-    Input input = Input.create();
+    MessageInput input = MessageInput.create();
     input.message(emptyToNull(msg));
     call(id, "revert").post(input, cb);
   }
@@ -81,7 +83,7 @@
     RestApi call = call(id, "topic");
     topic = emptyToNull(topic);
     if (topic != null) {
-      Input input = Input.create();
+      TopicInput input = TopicInput.create();
       input.topic(topic);
       call.put(input, NativeString.unwrap(cb));
     } else {
@@ -112,6 +114,17 @@
     return call(id, revision, "actions");
   }
 
+  public static void deleteAssignee(int id, AsyncCallback<AccountInfo> cb) {
+    change(id).view("assignee").delete(cb);
+  }
+
+  public static void setAssignee(int id, String user,
+      AsyncCallback<AccountInfo> cb) {
+    AssigneeInput input = AssigneeInput.create();
+    input.assignee(user);
+    change(id).view("assignee").put(user, cb);
+  }
+
   public static RestApi comments(int id) {
     return call(id, "comments");
   }
@@ -157,10 +170,14 @@
     return change(id).view("reviewers");
   }
 
-  public static RestApi suggestReviewers(int id, String q, int n) {
-    return change(id).view("suggest_reviewers")
-        .addParameter("q", q)
-        .addParameter("n", n);
+  public static RestApi suggestReviewers(int id, String q, int n, boolean e) {
+    RestApi api = change(id).view("suggest_reviewers")
+        .addParameter("n", n)
+        .addParameter("e", e);
+    if (q != null) {
+      api.addParameter("q", q);
+    }
+    return api;
   }
 
   public static RestApi vote(int id, int reviewer, String vote) {
@@ -178,12 +195,14 @@
   public static RestApi hashtags(int changeId) {
     return change(changeId).view("hashtags");
   }
+
   public static RestApi hashtag(int changeId, String hashtag) {
     return change(changeId).view("hashtags").id(hashtag);
   }
 
   /** Submit a specific revision of a change. */
-  public static void cherrypick(int id, String commit, String destination, String message, AsyncCallback<ChangeInfo> cb) {
+  public static void cherrypick(int id, String commit, String destination,
+      String message, AsyncCallback<ChangeInfo> cb) {
     CherryPickInput cherryPickInput = CherryPickInput.create();
     cherryPickInput.setMessage(message);
     cherryPickInput.setDestination(destination);
@@ -199,13 +218,15 @@
   }
 
   /** Submit a specific revision of a change. */
-  public static void submit(int id, String commit, AsyncCallback<SubmitInfo> cb) {
+  public static void submit(int id, String commit,
+      AsyncCallback<SubmitInfo> cb) {
     JavaScriptObject in = JavaScriptObject.createObject();
     call(id, commit, "submit").post(in, cb);
   }
 
   /** Publish a specific revision of a draft change. */
-  public static void publish(int id, String commit, AsyncCallback<JavaScriptObject> cb) {
+  public static void publish(int id, String commit,
+      AsyncCallback<JavaScriptObject> cb) {
     JavaScriptObject in = JavaScriptObject.createObject();
     call(id, commit, "publish").post(in, cb);
   }
@@ -216,7 +237,8 @@
   }
 
   /** Delete a specific draft patch set. */
-  public static void deleteRevision(int id, String commit, AsyncCallback<JavaScriptObject> cb) {
+  public static void deleteRevision(int id, String commit,
+      AsyncCallback<JavaScriptObject> cb) {
     revision(id, commit).delete(cb);
   }
 
@@ -238,21 +260,43 @@
   }
 
   /** Rebase a revision onto the branch tip or another change. */
-  public static void rebase(int id, String commit, String base, AsyncCallback<ChangeInfo> cb) {
+  public static void rebase(int id, String commit, String base,
+      AsyncCallback<ChangeInfo> cb) {
     RebaseInput rebaseInput = RebaseInput.create();
     rebaseInput.setBase(base);
     call(id, commit, "rebase").post(rebaseInput, cb);
   }
 
-  private static class Input extends JavaScriptObject {
-    final native void topic(String t) /*-{ if(t)this.topic=t; }-*/;
+  private static class MessageInput extends JavaScriptObject {
     final native void message(String m) /*-{ if(m)this.message=m; }-*/;
 
-    static Input create() {
-      return (Input) createObject();
+    static MessageInput create() {
+      return (MessageInput) createObject();
     }
 
-    protected Input() {
+    protected MessageInput() {
+    }
+  }
+
+  private static class AssigneeInput extends JavaScriptObject {
+    final native void assignee(String a) /*-{ if(a)this.assignee=a; }-*/;
+
+    static AssigneeInput create() {
+      return (AssigneeInput) createObject();
+    }
+
+    protected AssigneeInput() {
+    }
+  }
+
+  private static class TopicInput extends JavaScriptObject {
+    final native void topic(String t) /*-{ if(t)this.topic=t; }-*/;
+
+    static TopicInput create() {
+      return (TopicInput) createObject();
+    }
+
+    protected TopicInput() {
     }
   }
 
@@ -265,8 +309,9 @@
     public final native void topic(String t) /*-{ if(t)this.topic=t; }-*/;
     public final native void project(String p) /*-{ if(p)this.project=p; }-*/;
     public final native void subject(String s) /*-{ if(s)this.subject=s; }-*/;
-    public final native void baseChange(String b) /*-{ if(b)this.base_change=b; }-*/;
-    public final native void status(String s)  /*-{ if(s)this.status=s; }-*/;
+    public final native void status(String s) /*-{ if(s)this.status=s; }-*/;
+    public final native void baseChange(
+        String b) /*-{ if(b)this.base_change=b; }-*/;
 
     protected CreateChangeInput() {
     }
@@ -276,7 +321,9 @@
     static CherryPickInput create() {
       return (CherryPickInput) createObject();
     }
+
     final native void setDestination(String d) /*-{ this.destination = d; }-*/;
+
     final native void setMessage(String m) /*-{ this.message = m; }-*/;
 
     protected CherryPickInput() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
index b2334d1d..1c3026c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
@@ -37,6 +37,7 @@
   String changeTableColumnSize();
   String changeTableColumnStatus();
   String changeTableColumnOwner();
+  String changeTableColumnAssignee();
   String changeTableColumnProject();
   String changeTableColumnBranch();
   String changeTableColumnLastUpdate();
@@ -63,11 +64,14 @@
   String patchTableColumnComments();
   String patchTableColumnSize();
   String commitMessage();
+  String mergeList();
 
   String patchTablePrev();
   String patchTableNext();
   String patchTableOpenDiff();
 
+  String approvalTableEditAssigneeHint();
+
   String approvalTableAddReviewerHint();
   String approvalTableAddManyReviewersConfirmationDialogTitle();
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
index b7e2677..01921de 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
@@ -18,6 +18,7 @@
 changeTableColumnSize = Size
 changeTableColumnStatus = Status
 changeTableColumnOwner = Owner
+changeTableColumnAssignee = Assignee
 changeTableColumnProject = Project
 changeTableColumnBranch = Branch
 changeTableColumnLastUpdate = Updated
@@ -40,16 +41,18 @@
 keyExpandAllMessages = Expand all messages
 keyCollapseAllMessages = Collapse all messages
 
-
 patchTableColumnName = File Path
 patchTableColumnComments = Comments
 patchTableColumnSize = Size
 commitMessage = Commit Message
+mergeList = Merge List
 
 patchTablePrev = Previous file
 patchTableNext = Next file
 patchTableOpenDiff = Open diff
 
+approvalTableEditAssigneeHint = Name or Email
+
 approvalTableAddReviewerHint = Name or Email or Group
 approvalTableAddManyReviewersConfirmationDialogTitle = Adding Group Members as Reviewers
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
index 9c78955..86b2a82 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
@@ -49,6 +49,7 @@
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 
 public class ChangeTable extends NavigationTable<ChangeInfo> {
@@ -63,14 +64,16 @@
   private static final int C_SUBJECT = 3;
   private static final int C_STATUS = 4;
   private static final int C_OWNER = 5;
-  private static final int C_PROJECT = 6;
-  private static final int C_BRANCH = 7;
-  private static final int C_LAST_UPDATE = 8;
-  private static final int C_SIZE = 9;
-  private static final int BASE_COLUMNS = 10;
+  private static final int C_ASSIGNEE = 6;
+  private static final int C_PROJECT = 7;
+  private static final int C_BRANCH = 8;
+  private static final int C_LAST_UPDATE = 9;
+  private static final int C_SIZE = 10;
+  private static final int BASE_COLUMNS = 11;
 
   private final List<Section> sections;
   private int columns;
+  private final boolean showAssignee;
   private final boolean showLegacyId;
   private List<String> labelNames;
 
@@ -78,6 +81,7 @@
     super(Util.C.changeItemHelp());
     columns = BASE_COLUMNS;
     labelNames = Collections.emptyList();
+    showAssignee = Gerrit.info().change().showAssignee();
     showLegacyId = Gerrit.getUserPreferences().legacycidInChangeTable();
 
     if (Gerrit.isSignedIn()) {
@@ -90,6 +94,7 @@
     table.setText(0, C_SUBJECT, Util.C.changeTableColumnSubject());
     table.setText(0, C_STATUS, Util.C.changeTableColumnStatus());
     table.setText(0, C_OWNER, Util.C.changeTableColumnOwner());
+    table.setText(0, C_ASSIGNEE, Util.C.changeTableColumnAssignee());
     table.setText(0, C_PROJECT, Util.C.changeTableColumnProject());
     table.setText(0, C_BRANCH, Util.C.changeTableColumnBranch());
     table.setText(0, C_LAST_UPDATE, Util.C.changeTableColumnLastUpdate());
@@ -103,6 +108,9 @@
     if (!showLegacyId) {
       fmt.addStyleName(0, C_ID, Gerrit.RESOURCES.css().dataHeaderHidden());
     }
+    if (!showAssignee) {
+      fmt.addStyleName(0, C_ASSIGNEE, Gerrit.RESOURCES.css().dataHeaderHidden());
+    }
 
     table.addClickHandler(new ClickHandler() {
       @Override
@@ -163,6 +171,9 @@
     fmt.addStyleName(row, C_SUBJECT, Gerrit.RESOURCES.css().cSUBJECT());
     fmt.addStyleName(row, C_STATUS, Gerrit.RESOURCES.css().cSTATUS());
     fmt.addStyleName(row, C_OWNER, Gerrit.RESOURCES.css().cOWNER());
+    fmt.addStyleName(row, C_ASSIGNEE,
+        showAssignee ? Gerrit.RESOURCES.css().cASSIGNEE()
+            : Gerrit.RESOURCES.css().dataCellHidden());
     fmt.addStyleName(row, C_LAST_UPDATE, Gerrit.RESOURCES.css().cLastUpdate());
     fmt.addStyleName(row, C_SIZE, Gerrit.RESOURCES.css().cSIZE());
 
@@ -232,13 +243,30 @@
     }
 
     if (c.owner() != null) {
-      table.setWidget(row, C_OWNER, new AccountLinkPanel(c.owner(), status));
+      table.setWidget(row, C_OWNER,
+          AccountLinkPanel.withStatus(c.owner(), status));
     } else {
       table.setText(row, C_OWNER, "");
     }
 
+    if (showAssignee) {
+      if (c.assignee() != null) {
+        table.setWidget(row, C_ASSIGNEE,
+            AccountLinkPanel.forAssignee(c.assignee()));
+        if (Gerrit.getUserPreferences().highlightAssigneeInChangeTable()
+            && Objects.equals(c.assignee().getId(),
+                Gerrit.getUserAccount().getId())) {
+          table.getRowFormatter().addStyleName(row,
+              Gerrit.RESOURCES.css().cASSIGNEDTOME());
+        }
+      } else {
+        table.setText(row, C_ASSIGNEE, "");
+      }
+    }
+
     table.setWidget(row, C_PROJECT, new ProjectLink(c.projectNameKey()));
-    table.setWidget(row, C_BRANCH, new BranchLink(c.projectNameKey(), c
+    table.setWidget(row, C_BRANCH,
+        new BranchLink(c.projectNameKey(), c
         .status(), c.branch(), c.topic()));
     if (Gerrit.getUserPreferences().relativeDateInChangeTable()) {
       table.setText(row, C_LAST_UPDATE, relativeFormat(c.updated()));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
index 2f3ead3..4c01941 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.patches.SkippedLine;
@@ -40,7 +41,7 @@
 
 /** Tracks comment widgets for {@link DiffScreen}. */
 abstract class CommentManager {
-  private final PatchSet.Id base;
+  private final DiffObject base;
   private final PatchSet.Id revision;
   private final String path;
   private final CommentLinkProcessor commentLinkProcessor;
@@ -55,7 +56,7 @@
 
   CommentManager(
       DiffScreen host,
-      PatchSet.Id base,
+      DiffObject base,
       PatchSet.Id revision,
       String path,
       CommentLinkProcessor clp,
@@ -129,29 +130,30 @@
   }
 
   Side getStoredSideFromDisplaySide(DisplaySide side) {
-    if (side == DisplaySide.A && (base == null || base.get() < 0)) {
+    if (side == DisplaySide.A && base.isBaseOrAutoMerge() || base.isParent()) {
       return Side.PARENT;
     }
     return Side.REVISION;
   }
 
   int getParentNumFromDisplaySide(DisplaySide side) {
-    if (side == DisplaySide.A && base != null && base.get() < 0) {
-      return -base.get();
+    if (side == DisplaySide.A) {
+      return base.getParentNum();
     }
     return 0;
   }
 
   PatchSet.Id getPatchSetIdFromSide(DisplaySide side) {
-    if (side == DisplaySide.A && base != null && base.get() >= 0) {
-      return base;
+    if (side == DisplaySide.A && (base.isPatchSet() || base.isEdit())) {
+      return base.asPatchSetId();
     }
     return revision;
   }
 
   DisplaySide displaySide(CommentInfo info, DisplaySide forSide) {
     if (info.side() == Side.PARENT) {
-      return (base == null || base.get() < 0) ? DisplaySide.A : null;
+      return (base.isBaseOrAutoMerge() || base.isParent())
+          ? DisplaySide.A : null;
     }
     return forSide;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
index ce1d294..0b8e141 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.CommentApi;
 import com.google.gerrit.client.changes.CommentInfo;
@@ -31,7 +32,7 @@
 /** Collection of published and draft comments loaded from the server. */
 class CommentsCollections {
   private final String path;
-  private final PatchSet.Id base;
+  private final DiffObject base;
   private final PatchSet.Id revision;
   private NativeMap<JsArray<CommentInfo>> publishedBaseAll;
   private NativeMap<JsArray<CommentInfo>> publishedRevisionAll;
@@ -40,28 +41,28 @@
   JsArray<CommentInfo> draftsBase;
   JsArray<CommentInfo> draftsRevision;
 
-  CommentsCollections(PatchSet.Id base, PatchSet.Id revision, String path) {
+  CommentsCollections(DiffObject base, PatchSet.Id revision, String path) {
     this.path = path;
     this.base = base;
     this.revision = revision;
   }
 
   void load(CallbackGroup group) {
-    if (base != null && base.get() > 0) {
-      CommentApi.comments(base, group.add(publishedBase()));
+    if (base.isPatchSet()) {
+      CommentApi.comments(base.asPatchSetId(), group.add(publishedBase()));
     }
     CommentApi.comments(revision, group.add(publishedRevision()));
 
     if (Gerrit.isSignedIn()) {
-      if (base != null && base.get() > 0) {
-        CommentApi.drafts(base, group.add(draftsBase()));
+      if (base.isPatchSet()) {
+        CommentApi.drafts(base.asPatchSetId(), group.add(draftsBase()));
       }
       CommentApi.drafts(revision, group.add(draftsRevision()));
     }
   }
 
   boolean hasCommentForPath(String filePath) {
-    if (base != null && base.get() > 0) {
+    if (base.isPatchSet()) {
       JsArray<CommentInfo> forBase = publishedBaseAll.get(filePath);
       if (forBase != null && forBase.length() > 0) {
         return true;
@@ -110,9 +111,9 @@
       for (CommentInfo c : Natives.asList(list)) {
         if (c.side() == Side.REVISION) {
           result.push(c);
-        } else if (base == null && !c.hasParent()) {
+        } else if (base.isBaseOrAutoMerge() && !c.hasParent()) {
           result.push(c);
-        } else if (base != null && c.parent() == -base.get()) {
+        } else if (base.isParent() && c.parent() == base.getParentNum()) {
           result.push(c);
         }
       }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
index 8935e36..a22d4bd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.extensions.client.DiffPreferencesInfo.WHOLE_FILE_CONTEXT;
 import static java.lang.Double.POSITIVE_INFINITY;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.account.DiffPreferences;
@@ -96,7 +97,7 @@
   }
 
   private final Change.Id changeId;
-  final PatchSet.Id base;
+  final DiffObject base;
   final PatchSet.Id revision;
   final String path;
   final DiffPreferences prefs;
@@ -123,15 +124,15 @@
   Header header;
 
   DiffScreen(
-      PatchSet.Id base,
-      PatchSet.Id revision,
+      DiffObject base,
+      DiffObject revision,
       String path,
       DisplaySide startSide,
       int startLine,
       DiffView diffScreenType) {
     this.base = base;
-    this.revision = revision;
-    this.changeId = revision.getParentKey();
+    this.revision = revision.asPatchSetId();
+    this.changeId = revision.asPatchSetId().getParentKey();
     this.path = path;
     this.startSide = startSide;
     this.startLine = startLine;
@@ -173,7 +174,7 @@
     }));
 
     DiffApi.diff(revision, path)
-      .base(base)
+      .base(base.asPatchSetId())
       .wholeFile()
       .intraline(prefs.intralineDifference())
       .ignoreWhitespace(prefs.ignoreWhitespace())
@@ -639,7 +640,7 @@
   }
 
   private void toggleShowIntraline() {
-    prefs.intralineDifference(!prefs.intralineDifference());
+    prefs.intralineDifference(!Boolean.valueOf(prefs.intralineDifference()));
     setShowIntraline(prefs.intralineDifference());
     prefsAction.update();
   }
@@ -789,11 +790,10 @@
         group.addListener(new GerritCallback<Void>() {
           @Override
           public void onSuccess(Void result) {
-            String b = base != null ? String.valueOf(base.get()) : null;
             String rev = String.valueOf(revision.get());
             Gerrit.display(
-              PageLinks.toChange(changeId, b, rev),
-              new ChangeScreen(changeId, b, rev, openReplyBox,
+              PageLinks.toChange(changeId, base.asString(), rev),
+              new ChangeScreen(changeId, base, rev, openReplyBox,
                   FileTable.Mode.REVIEW));
           }
         });
@@ -901,7 +901,7 @@
     String nextPath = header.getNextPath();
     if (nextPath != null) {
       DiffApi.diff(revision, nextPath)
-        .base(base)
+        .base(base.asPatchSetId())
         .wholeFile()
         .intraline(prefs.intralineDifference())
         .ignoreWhitespace(prefs.ignoreWhitespace())
@@ -924,7 +924,7 @@
   void reloadDiffInfo() {
     final int id = ++reloadVersionId;
     DiffApi.diff(revision, path)
-      .base(base)
+      .base(base.asPatchSetId())
       .wholeFile()
       .intraline(prefs.intralineDifference())
       .ignoreWhitespace(prefs.ignoreWhitespace())
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
index 392ad2f..54b55f04 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.account.DiffPreferences;
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
@@ -66,11 +67,12 @@
   private ChangeType changeType;
   Scrollbar scrollbar;
 
-  DiffTable(DiffScreen parent, PatchSet.Id base, PatchSet.Id revision, String path) {
-    patchSetSelectBoxA = new PatchSetSelectBox(
-        parent, DisplaySide.A, revision.getParentKey(), base, path);
-    patchSetSelectBoxB = new PatchSetSelectBox(
-        parent, DisplaySide.B, revision.getParentKey(), revision, path);
+  DiffTable(DiffScreen parent, DiffObject base, DiffObject revision,
+      String path) {
+    patchSetSelectBoxA = new PatchSetSelectBox(parent, DisplaySide.A,
+        revision.asPatchSetId().getParentKey(), base, path);
+    patchSetSelectBoxB = new PatchSetSelectBox(parent, DisplaySide.B,
+        revision.asPatchSetId().getParentKey(), revision, path);
     PatchSetSelectBox.link(patchSetSelectBoxA, patchSetSelectBoxB);
 
     this.scrollbar = new Scrollbar(this);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
index f377038..f3b9886 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.account.DiffPreferences;
@@ -87,7 +88,7 @@
   @UiField Image preferences;
 
   private final KeyCommandSet keys;
-  private final PatchSet.Id base;
+  private final DiffObject base;
   private final PatchSet.Id patchSetId;
   private final String path;
   private final DiffView diffScreenType;
@@ -99,12 +100,12 @@
   private PreferencesAction prefsAction;
   private ReviewedState reviewedState;
 
-  Header(KeyCommandSet keys, PatchSet.Id base, PatchSet.Id patchSetId,
+  Header(KeyCommandSet keys, DiffObject base, DiffObject patchSetId,
       String path, DiffView diffSreenType, DiffPreferences prefs) {
     initWidget(uiBinder.createAndBindUi(this));
     this.keys = keys;
     this.base = base;
-    this.patchSetId = patchSetId;
+    this.patchSetId = patchSetId.asPatchSetId();
     this.path = path;
     this.diffScreenType = diffSreenType;
     this.prefs = prefs;
@@ -113,15 +114,17 @@
       reviewed.getElement().getStyle().setVisibility(Visibility.HIDDEN);
     }
     SafeHtml.setInnerHTML(filePath, formatPath(path));
-    up.setTargetHistoryToken(PageLinks.toChange(
-        patchSetId.getParentKey(),
-        base != null ? base.getId() : null, patchSetId.getId()));
+    up.setTargetHistoryToken(
+        PageLinks.toChange(patchSetId.asPatchSetId().getParentKey(),
+            base.asString(), patchSetId.asPatchSetId().getId()));
   }
 
   public static SafeHtml formatPath(String path) {
     SafeHtmlBuilder b = new SafeHtmlBuilder();
     if (Patch.COMMIT_MSG.equals(path)) {
       return b.append(Util.C.commitMessage());
+    } else if (Patch.MERGE_LIST.equals(path)) {
+      return b.append(Util.C.mergeList());
     }
 
     int s = path.lastIndexOf('/') + 1;
@@ -145,16 +148,17 @@
 
   @Override
   protected void onLoad() {
-    DiffApi.list(patchSetId, base, new GerritCallback<NativeMap<FileInfo>>() {
-      @Override
-      public void onSuccess(NativeMap<FileInfo> result) {
-        files = result.values();
-        FileInfo.sortFileInfoByPath(files);
-        fileNumber.setInnerText(
-            Integer.toString(Natives.asList(files).indexOf(result.get(path)) + 1));
-        fileCount.setInnerText(Integer.toString(files.length()));
-      }
-    });
+    DiffApi.list(patchSetId, base.asPatchSetId(),
+        new GerritCallback<NativeMap<FileInfo>>() {
+          @Override
+          public void onSuccess(NativeMap<FileInfo> result) {
+            files = result.values();
+            FileInfo.sortFileInfoByPath(files);
+            fileNumber.setInnerText(Integer
+                .toString(Natives.asList(files).indexOf(result.get(path)) + 1));
+            fileCount.setInnerText(Integer.toString(files.length()));
+          }
+        });
 
     if (Gerrit.isSignedIn()) {
       ChangeApi.revision(patchSetId).view("files")
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
index bc37abb..b07a199 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.blame.BlameInfo;
@@ -67,13 +68,13 @@
   private String path;
   private Change.Id changeId;
   private PatchSet.Id revision;
-  private PatchSet.Id idActive;
+  private DiffObject idActive;
   private PatchSetSelectBox other;
 
   PatchSetSelectBox(DiffScreen parent,
       DisplaySide side,
       Change.Id changeId,
-      PatchSet.Id revision,
+      DiffObject diffObject,
       String path) {
     initWidget(uiBinder.createAndBindUi(this));
     icon.setTitle(PatchUtil.C.addFileCommentToolTip());
@@ -83,8 +84,8 @@
     this.side = side;
     this.sideA = side == DisplaySide.A;
     this.changeId = changeId;
-    this.revision = revision;
-    this.idActive = (sideA && revision == null) ? null : revision;
+    this.revision = diffObject.asPatchSetId();
+    this.idActive = diffObject;
     this.path = path;
   }
 
@@ -93,19 +94,22 @@
     InlineHyperlink selectedLink = null;
     if (sideA) {
       if (parents <= 1) {
-        InlineHyperlink link = createLink(PatchUtil.C.patchBase(), null);
+        InlineHyperlink link =
+            createLink(PatchUtil.C.patchBase(), DiffObject.base());
         linkPanel.add(link);
         selectedLink = link;
       } else {
         for (int i = parents; i > 0; i--) {
           PatchSet.Id id = new PatchSet.Id(changeId, -i);
-          InlineHyperlink link = createLink(Util.M.diffBaseParent(i), id);
+          InlineHyperlink link =
+              createLink(Util.M.diffBaseParent(i), DiffObject.patchSet(id));
           linkPanel.add(link);
           if (revision != null && id.equals(revision)) {
             selectedLink = link;
           }
         }
-        InlineHyperlink link = createLink(Util.C.autoMerge(), null);
+        InlineHyperlink link =
+            createLink(Util.C.autoMerge(), DiffObject.autoMerge());
         linkPanel.add(link);
         if (selectedLink == null) {
           selectedLink = link;
@@ -115,7 +119,7 @@
     for (int i = 0; i < list.length(); i++) {
       RevisionInfo r = list.get(i);
       InlineHyperlink link = createLink(r.id(),
-          new PatchSet.Id(changeId, r._number()));
+          DiffObject.patchSet(new PatchSet.Id(changeId, r._number())));
       linkPanel.add(link);
       if (revision != null && r.id().equals(revision.getId())) {
         selectedLink = link;
@@ -128,11 +132,11 @@
     if (meta == null) {
       return;
     }
-    if (!Patch.COMMIT_MSG.equals(path)) {
+    if (!Patch.isMagic(path)) {
       linkPanel.add(createDownloadLink());
     }
-    if (!binary && open && idActive != null && Gerrit.isSignedIn()) {
-      if ((editExists && idActive.get() == 0)
+    if (!binary && open && !idActive.isBaseOrAutoMerge() && Gerrit.isSignedIn()) {
+      if ((editExists && idActive.isEdit())
           || (!editExists && current)) {
         linkPanel.add(createEditIcon());
       }
@@ -147,7 +151,7 @@
 
   void setUpBlame(final CodeMirror cm, final boolean isBase,
       final PatchSet.Id rev, final String path) {
-    if (!Patch.COMMIT_MSG.equals(path) && Gerrit.isSignedIn()
+    if (!Patch.isMagic(path) && Gerrit.isSignedIn()
         && Gerrit.info().change().allowBlame()) {
       Anchor blameIcon = createBlameIcon();
       blameIcon.addClickHandler(new ClickHandler() {
@@ -172,7 +176,9 @@
   }
 
   private Widget createEditIcon() {
-    PatchSet.Id id = (idActive == null) ? other.idActive : idActive;
+    PatchSet.Id id = idActive.isBaseOrAutoMerge()
+        ? other.idActive.asPatchSetId()
+        : idActive.asPatchSetId();
     Anchor anchor = new Anchor(
         new ImageResourceRenderer().render(Gerrit.RESOURCES.edit()),
         "#" + Dispatcher.toEditScreen(id, path));
@@ -192,27 +198,29 @@
     b.other = a;
   }
 
-  private InlineHyperlink createLink(String label, PatchSet.Id id) {
+  private InlineHyperlink createLink(String label, DiffObject id) {
     assert other != null;
     if (sideA) {
-      assert other.idActive != null;
+      assert !other.idActive.isBaseOrAutoMerge();
     }
-    PatchSet.Id diffBase = sideA ? id : other.idActive;
-    PatchSet.Id revision = sideA ? other.idActive : id;
+    DiffObject diffBase = sideA ? id : other.idActive;
+    DiffObject revision = sideA ? other.idActive : id;
 
     return new InlineHyperlink(label,
         parent.isSideBySide()
-            ? Dispatcher.toSideBySide(diffBase, revision, path)
-            : Dispatcher.toUnified(diffBase, revision, path));
+            ? Dispatcher.toSideBySide(diffBase, revision.asPatchSetId(), path)
+            : Dispatcher.toUnified(diffBase, revision.asPatchSetId(), path));
   }
 
   private Anchor createDownloadLink() {
-    PatchSet.Id id = (idActive == null) ? other.idActive : idActive;
-    String sideURL = (idActive == null) ? "1" : "0";
+    DiffObject diffObject = idActive.isBaseOrAutoMerge()
+        ? other.idActive : idActive;
+    String sideURL = idActive.isBaseOrAutoMerge() ? "1" : "0";
     String base = GWT.getHostPageBaseURL() + "cat/";
     Anchor anchor = new Anchor(
         new ImageResourceRenderer().render(Gerrit.RESOURCES.downloadIcon()),
-        base + KeyUtil.encode(id + "," + path) + "^" + sideURL);
+        base + KeyUtil.encode(diffObject.asPatchSetId() + "," + path) + "^"
+            + sideURL);
     anchor.setTitle(PatchUtil.C.download());
     return anchor;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
index 78d01db..ef1d4bd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
@@ -260,7 +260,7 @@
 
   @UiHandler("intralineDifference")
   void onIntralineDifference(ValueChangeEvent<Boolean> e) {
-    prefs.intralineDifference(e.getValue());
+    prefs.intralineDifference(Boolean.valueOf(e.getValue()));
     if (view != null) {
       view.setShowIntraline(prefs.intralineDifference());
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
index dbe7e5d..6e2120a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
@@ -16,6 +16,7 @@
 
 import static java.lang.Double.POSITIVE_INFINITY;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
@@ -25,7 +26,6 @@
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.Scheduler;
 import com.google.gwt.core.client.Scheduler.ScheduledCommand;
@@ -69,8 +69,8 @@
   private SideBySideCommentManager commentManager;
 
   public SideBySide(
-      PatchSet.Id base,
-      PatchSet.Id revision,
+      DiffObject base,
+      DiffObject revision,
       String path,
       DisplaySide startSide,
       int startLine) {
@@ -192,9 +192,8 @@
     cmA = newCm(diff.metaA(), diff.textA(), diffTable.cmA);
     cmB = newCm(diff.metaB(), diff.textB(), diffTable.cmB);
 
-    boolean reviewingBase = base == null;
-    getDiffTable().setUpBlameIconA(cmA, reviewingBase,
-        reviewingBase ? revision : base, path);
+    getDiffTable().setUpBlameIconA(cmA, base.isBaseOrAutoMerge(),
+        base.isBaseOrAutoMerge() ? revision : base.asPatchSetId(), path);
     getDiffTable().setUpBlameIconB(cmB, revision, path);
 
     cmA.extras().side(DisplaySide.A);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
index bcb7dac..fab6e6b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
@@ -29,7 +30,7 @@
 /** Tracks comment widgets for {@link SideBySide}. */
 class SideBySideCommentManager extends CommentManager {
   SideBySideCommentManager(SideBySide host,
-      PatchSet.Id base, PatchSet.Id revision,
+      DiffObject base, PatchSet.Id revision,
       String path,
       CommentLinkProcessor clp,
       boolean open) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
index 2296796..5e8d7cc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.resources.client.CssResource;
@@ -46,7 +46,7 @@
 
   private boolean visibleA;
 
-  SideBySideTable(SideBySide parent, PatchSet.Id base, PatchSet.Id revision,
+  SideBySideTable(SideBySide parent, DiffObject base, DiffObject revision,
       String path) {
     super(parent, base, revision, path);
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
index a231580..566d87c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
@@ -16,6 +16,7 @@
 
 import static java.lang.Double.POSITIVE_INFINITY;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.diff.UnifiedChunkManager.LineRegionInfo;
@@ -25,7 +26,6 @@
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArrayString;
@@ -69,8 +69,8 @@
   private boolean autoHideDiffTableHeader;
 
   public Unified(
-      PatchSet.Id base,
-      PatchSet.Id revision,
+      DiffObject base,
+      DiffObject revision,
       String path,
       DisplaySide startSide,
       int startLine) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java
index 8968bc7..21356fc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
@@ -43,7 +44,7 @@
   private final Map<Integer, CommentGroup> duplicates;
 
   UnifiedCommentManager(Unified host,
-      PatchSet.Id base, PatchSet.Id revision,
+      DiffObject base, PatchSet.Id revision,
       String path,
       CommentLinkProcessor clp,
       boolean open) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java
index 72b3e49..e3317c4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.client.DiffObject;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.resources.client.CssResource;
@@ -45,7 +45,7 @@
   @UiField Element cm;
   @UiField static DiffTableStyle style;
 
-  UnifiedTable(Unified parent, PatchSet.Id base, PatchSet.Id revision,
+  UnifiedTable(Unified parent, DiffObject base, DiffObject revision,
       String path) {
     super(parent, base, revision, path);
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
index da7ca44..490e028 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
@@ -99,7 +99,6 @@
     String hideBase();
   }
 
-  private final PatchSet.Id base;
   private final PatchSet.Id revision;
   private final String path;
   private final int startLine;
@@ -130,8 +129,7 @@
   private HandlerRegistration closeHandler;
   private int generation;
 
-  public EditScreen(PatchSet.Id base, Patch.Key patch, int startLine) {
-    this.base = base;
+  public EditScreen(Patch.Key patch, int startLine) {
     this.revision = patch.getParentKey();
     this.path = patch.get();
     this.startLine = startLine - 1;
@@ -232,7 +230,6 @@
       // TODO(davido): We probably want to create dedicated GET EditScreenMeta
       // REST endpoint. Abuse GET diff for now, as it retrieves links we need.
       DiffApi.diff(revision, path)
-        .base(base)
         .webLinksOnly()
         .get(group1.addFinal(new AsyncCallback<DiffInfo>() {
           @Override
@@ -614,7 +611,7 @@
     sbs.setHTML(new ImageResourceRenderer()
         .render(Gerrit.RESOURCES.sideBySideDiff()));
     sbs.setTargetHistoryToken(
-        Dispatcher.toPatch("sidebyside", base, new Patch.Key(revision, path)));
+        Dispatcher.toPatch("sidebyside", null, new Patch.Key(revision, path)));
     sbs.setTitle(PatchUtil.C.sideBySideDiff());
     linkPanel.add(sbs);
 
@@ -622,7 +619,7 @@
     unified.setHTML(new ImageResourceRenderer()
         .render(Gerrit.RESOURCES.unifiedDiff()));
     unified.setTargetHistoryToken(
-        Dispatcher.toPatch("unified", base, new Patch.Key(revision, path)));
+        Dispatcher.toPatch("unified", null, new Patch.Key(revision, path)));
     unified.setTitle(PatchUtil.C.unifiedDiff());
     linkPanel.add(unified);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
index 4190672..4076296 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
@@ -449,6 +449,11 @@
   white-space: nowrap;
 }
 
+.changeTable .cASSIGNEDTOME {
+  background: #ffe9d6 !important;
+}
+
+.changeTable .cASSIGNEE,
 .changeTable .cOWNER,
 .changeTable .cSTATUS {
   white-space: nowrap;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java
index 93be87b..760f06d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.rpc.RestApi;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java
index ed41b65..5bcdc6b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client.groups;
 
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupList.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupList.java
index a24e1dc..f51ecb8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupList.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupList.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.groups;
 
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gwt.core.client.JsArray;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java
index 5532285..5e23049 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.groups;
 
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gwt.user.client.rpc.AsyncCallback;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java
index a96624a..983d48c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.client.ui;
 
-import com.google.gerrit.client.groups.GroupInfo;
 import com.google.gerrit.client.groups.GroupMap;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.reviewdb.client.AccountGroup;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java
index eb3b1ff..dd9f369 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java
@@ -22,30 +22,44 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.user.client.ui.FlowPanel;
 
+import java.util.function.Function;
+
 /** Link to any user's account dashboard. */
 public class AccountLinkPanel extends FlowPanel {
-  public AccountLinkPanel(AccountInfo info) {
-    this(info, Change.Status.NEW);
+  public static AccountLinkPanel create(AccountInfo ai) {
+    return withStatus(ai, Change.Status.NEW);
   }
 
-  public AccountLinkPanel(AccountInfo info, Change.Status status) {
+  public static AccountLinkPanel withStatus(AccountInfo ai,
+      Change.Status status) {
+    return new AccountLinkPanel(
+        ai, name -> PageLinks.toAccountQuery(name, status));
+  }
+
+  public static AccountLinkPanel forAssignee(AccountInfo ai) {
+    return new AccountLinkPanel(ai, PageLinks::toAssigneeQuery);
+  }
+
+  private AccountLinkPanel(AccountInfo ai,
+      Function<String, String> nameToQuery) {
     addStyleName(Gerrit.RESOURCES.css().accountLinkPanel());
 
     InlineHyperlink l =
-        new InlineHyperlink(FormatUtil.name(info), PageLinks.toAccountQuery(
-            owner(info), status)) {
-      @Override
-      public void go() {
-        Gerrit.display(getTargetHistoryToken());
-      }
-    };
-    l.setTitle(FormatUtil.nameEmail(info));
+        new InlineHyperlink(
+            FormatUtil.name(ai),
+            nameToQuery.apply(name(ai))) {
+          @Override
+          public void go() {
+            Gerrit.display(getTargetHistoryToken());
+          }
+        };
+    l.setTitle(FormatUtil.nameEmail(ai));
 
-    add(new AvatarImage(info));
+    add(new AvatarImage(ai));
     add(l);
   }
 
-  public static String owner(AccountInfo ai) {
+  private static String name(AccountInfo ai) {
     if (ai.email() != null) {
       return ai.email();
     } else if (ai.name() != null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
index 3702e68..bfeeaec 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
@@ -61,7 +61,8 @@
 
     public static String format(AccountInfo info, String query) {
       String s = FormatUtil.nameEmail(info);
-      if (!containsQuery(s, query) && info.secondaryEmails() != null) {
+      if (query != null && !containsQuery(s, query) &&
+          info.secondaryEmails() != null) {
         for (String email : Natives.asList(info.secondaryEmails())) {
           AccountInfo info2 = AccountInfo.create(info._accountId(), info.name(),
               email, info.username());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
index 62b8f2e..57cd849 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
@@ -13,6 +13,8 @@
 // limitations under the License.
 package com.google.gerrit.client.ui;
 
+import com.google.gwt.event.dom.client.FocusEvent;
+import com.google.gwt.event.dom.client.FocusHandler;
 import com.google.gwt.event.dom.client.KeyCodes;
 import com.google.gwt.event.dom.client.KeyDownEvent;
 import com.google.gwt.event.dom.client.KeyDownHandler;
@@ -42,6 +44,7 @@
 
   public RemoteSuggestBox(SuggestOracle oracle) {
     remoteSuggestOracle = new RemoteSuggestOracle(oracle);
+    remoteSuggestOracle.setServeSuggestions(true);
     display = new DefaultSuggestionDisplay();
 
     textBox = new HintTextBox();
@@ -49,7 +52,6 @@
       @Override
       public void onKeyDown(KeyDownEvent e) {
         submitOnSelection = false;
-
         if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
           CloseEvent.fire(RemoteSuggestBox.this, RemoteSuggestBox.this);
         } else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
@@ -70,10 +72,11 @@
     suggestBox.addSelectionHandler(new SelectionHandler<Suggestion>() {
       @Override
       public void onSelection(SelectionEvent<Suggestion> event) {
-        textBox.setFocus(true);
         if (submitOnSelection) {
           SelectionEvent.fire(RemoteSuggestBox.this, getText());
         }
+        remoteSuggestOracle.cancelOutstandingRequest();
+        display.hideSuggestions();
       }
     });
     initWidget(suggestBox);
@@ -134,4 +137,23 @@
   public HandlerRegistration addCloseHandler(CloseHandler<RemoteSuggestBox> h) {
     return addHandler(h, CloseEvent.getType());
   }
+
+  public void selectAll() {
+    suggestBox.getValueBox().selectAll();
+  }
+
+  public void enableDefaultSuggestions() {
+    textBox.addFocusHandler(new FocusHandler() {
+      @Override
+      public void onFocus(FocusEvent focusEvent) {
+        if (textBox.getText().equals("")) {
+          suggestBox.showSuggestionList();
+        }
+      }
+    });
+  }
+
+  public void setServeSuggestionsOnOracle(boolean serveSuggestions) {
+    remoteSuggestOracle.setServeSuggestions(serveSuggestions);
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
index 943be7e..3d99883 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
@@ -82,7 +82,6 @@
       Modes.I.htmlmixed(),
       Modes.I.http(),
       Modes.I.idl(),
-      Modes.I.jade(),
       Modes.I.javascript(),
       Modes.I.jinja2(),
       Modes.I.jsx(),
@@ -110,6 +109,7 @@
       Modes.I.powershell(),
       Modes.I.properties(),
       Modes.I.protobuf(),
+      Modes.I.pug(),
       Modes.I.puppet(),
       Modes.I.python(),
       Modes.I.q(),
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
index 668a57f..218b96c 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
@@ -67,7 +67,6 @@
   @Source("htmlmixed.js") @DoNotEmbed DataResource htmlmixed();
   @Source("http.js") @DoNotEmbed DataResource http();
   @Source("idl.js") @DoNotEmbed DataResource idl();
-  @Source("jade.js") @DoNotEmbed DataResource jade();
   @Source("javascript.js") @DoNotEmbed DataResource javascript();
   @Source("jinja2.js") @DoNotEmbed DataResource jinja2();
   @Source("jsx.js") @DoNotEmbed DataResource jsx();
@@ -95,6 +94,7 @@
   @Source("powershell.js") @DoNotEmbed DataResource powershell();
   @Source("properties.js") @DoNotEmbed DataResource properties();
   @Source("protobuf.js") @DoNotEmbed DataResource protobuf();
+  @Source("pug.js") @DoNotEmbed DataResource pug();
   @Source("puppet.js") @DoNotEmbed DataResource puppet();
   @Source("python.js") @DoNotEmbed DataResource python();
   @Source("q.js") @DoNotEmbed DataResource q();
diff --git a/gerrit-httpd/BUCK b/gerrit-httpd/BUCK
index d52963a..0b0499c 100644
--- a/gerrit-httpd/BUCK
+++ b/gerrit-httpd/BUCK
@@ -73,7 +73,6 @@
     '//lib/jgit/org.eclipse.jgit.junit:junit',
     '//lib/joda:joda-time',
   ],
-  source_under_test = [':httpd'],
   # TODO(sop) Remove after Buck supports Eclipse
   visibility = ['//tools/eclipse:classpath'],
 )
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index fa2e0e3..f34f488 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -17,6 +17,7 @@
 import static java.util.concurrent.TimeUnit.HOURS;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.httpd.WebSessionManager.Key;
 import com.google.gerrit.httpd.WebSessionManager.Val;
@@ -109,6 +110,7 @@
   }
 
   @Override
+  @Nullable
   public String getXGerritAuth() {
     return isSignedIn() ? val.getAuth() : null;
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java
deleted file mode 100644
index c1a0f44..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2013 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.httpd;
-
-import org.eclipse.jgit.lib.Config;
-
-public class GerritOptions {
-  private final boolean headless;
-  private final boolean slave;
-  private final boolean enablePolyGerrit;
-  private final boolean forcePolyGerritDev;
-
-  public GerritOptions(Config cfg, boolean headless, boolean slave,
-      boolean forcePolyGerritDev) {
-    this.headless = headless;
-    this.slave = slave;
-    this.enablePolyGerrit = forcePolyGerritDev
-        || cfg.getBoolean("gerrit", null, "enablePolyGerrit", false);
-    this.forcePolyGerritDev = forcePolyGerritDev;
-  }
-
-  public boolean enableDefaultUi() {
-    return !headless && !enablePolyGerrit;
-  }
-
-  public boolean enableMasterFeatures() {
-    return !slave;
-  }
-
-  public boolean enablePolyGerrit() {
-    return !headless && enablePolyGerrit;
-  }
-
-  public boolean forcePolyGerritDev() {
-    return !headless && forcePolyGerritDev;
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
index 5146b31..7935bb6 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd;
 
-import static com.google.gerrit.reviewdb.client.AuthType.OAUTH;
+import static com.google.gerrit.extensions.client.AuthType.OAUTH;
 
 import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
 import com.google.gerrit.server.config.AuthConfig;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
new file mode 100644
index 0000000..3f419ed
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2016 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.httpd;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+
+/**
+ * HttpServletResponse wrapper to allow response status code override.
+ *
+ * Differently from the normal HttpServletResponse, this class allows multiple
+ * filters to override the response http status code.
+ */
+public class HttpServletResponseRecorder extends HttpServletResponseWrapper {
+  private static final Logger log = LoggerFactory
+      .getLogger(HttpServletResponseWrapper.class);
+  private static final String LOCATION_HEADER = "Location";
+
+  private int status;
+  private String statusMsg = "";
+  private Map<String, String> headers = new HashMap<>();
+
+  /**
+   * Constructs a response recorder wrapping the given response.
+   *
+   * @param response the response to be wrapped
+   */
+  public HttpServletResponseRecorder(HttpServletResponse response) {
+    super(response);
+  }
+
+  @Override
+  public void sendError(int sc) throws IOException {
+    this.status = sc;
+  }
+
+  @Override
+  public void sendError(int sc, String msg) throws IOException {
+    this.status = sc;
+    this.statusMsg = msg;
+  }
+
+  @Override
+  public void sendRedirect(String location) throws IOException {
+    this.status = SC_MOVED_TEMPORARILY;
+    setHeader(LOCATION_HEADER, location);
+  }
+
+  @Override
+  public void setHeader(String name, String value) {
+    super.setHeader(name, value);
+    headers.put(name, value);
+  }
+
+  @SuppressWarnings("all")
+  // @Override is omitted for backwards compatibility with servlet-api 2.5
+  // TODO: Remove @SuppressWarnings and add @Override when Google upgrades
+  //       to servlet-api 3.1
+  public int getStatus() {
+    return status;
+  }
+
+  void play() throws IOException {
+    if (status != 0) {
+      log.debug("Replaying {} {}", status, statusMsg);
+
+      if (status == SC_MOVED_TEMPORARILY) {
+        super.sendRedirect(headers.get(LOCATION_HEADER));
+      } else {
+        super.sendError(status, statusMsg);
+      }
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index b06f370..fab0aeb 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.AccessPath;
@@ -141,12 +142,16 @@
       return false;
     }
 
-    if (!authConfig.isLdapAuthType()
-        && !passwordMatchesTheUserGeneratedOne(who, username, password)) {
-      log.warn("Authentication failed for " + username
-          + ": password does not match the one stored in Gerrit");
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
+    GitBasicAuthPolicy gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
+    if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP
+        || gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP) {
+      if (passwordMatchesTheUserGeneratedOne(who, username, password)) {
+        return succeedAuthentication(who);
+      }
+    }
+
+    if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP) {
+      return failAuthentication(rsp, username);
     }
 
     AuthRequest whoAuth = AuthRequest.forUser(username);
@@ -158,8 +163,7 @@
       return true;
     } catch (NoSuchUserException e) {
       if (password.equals(who.getPassword(who.getUserName()))) {
-        setUserIdentified(who.getAccount().getId());
-        return true;
+        return succeedAuthentication(who);
       }
       log.warn("Authentication failed for " + username, e);
       rsp.sendError(SC_UNAUTHORIZED);
@@ -175,6 +179,19 @@
     }
   }
 
+  private boolean succeedAuthentication(final AccountState who) {
+    setUserIdentified(who.getAccount().getId());
+    return true;
+  }
+
+  private boolean failAuthentication(Response rsp, String username)
+      throws IOException {
+    log.warn("Authentication failed for {}: password does not match the one"
+        + " stored in Gerrit", username);
+    rsp.sendError(SC_UNAUTHORIZED);
+    return false;
+  }
+
   private void setUserIdentified(Account.Id id) {
     WebSession ws = session.get();
     ws.setUserAccountId(id);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
index 210800d..7e71639 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
@@ -90,7 +90,10 @@
       }
 
       CurrentUser self = session.get().getUser();
-      if (!self.getCapabilities().canRunAs()) {
+      if (!self.getCapabilities().canRunAs()
+          // Always disallow for anonymous users, even if permitted by the ACL,
+          // because that would be crazy.
+          || !self.isIdentifiedUser()) {
         replyError(req, res,
             SC_FORBIDDEN,
             "not permitted to use " + RUN_AS,
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java
new file mode 100644
index 0000000..f6efb61
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2016 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.httpd;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.servlet.ServletModule;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class UniversalWebLoginFilter implements Filter {
+  private final DynamicItem<WebSession> session;
+  private final DynamicSet<WebLoginListener> webLoginListeners;
+  private final Provider<CurrentUser> userProvider;
+
+  public static ServletModule module() {
+    return new ServletModule() {
+      @Override
+      protected void configureServlets() {
+        filter("/login*", "/logout*").through(UniversalWebLoginFilter.class);
+        bind(UniversalWebLoginFilter.class).in(Singleton.class);
+
+        DynamicSet.setOf(binder(), WebLoginListener.class);
+      }
+    };
+  }
+
+  @Inject
+  public UniversalWebLoginFilter(DynamicItem<WebSession> session,
+      DynamicSet<WebLoginListener> webLoginListeners,
+      Provider<CurrentUser> userProvider) {
+    this.session = session;
+    this.webLoginListeners = webLoginListeners;
+    this.userProvider = userProvider;
+  }
+
+  @Override
+  public void init(FilterConfig filterConfig) throws ServletException {
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response,
+      FilterChain chain) throws IOException, ServletException {
+    HttpServletRequest httpRequest = (HttpServletRequest) request;
+    HttpServletResponseRecorder wrappedResponse =
+        new HttpServletResponseRecorder((HttpServletResponse) response);
+
+    Optional<IdentifiedUser> loggedInUserBefore = loggedInUser();
+    chain.doFilter(request, wrappedResponse);
+    Optional<IdentifiedUser> loggedInUserAfter = loggedInUser();
+
+    if (!loggedInUserBefore.isPresent() && loggedInUserAfter.isPresent()) {
+      for (WebLoginListener loginListener : webLoginListeners) {
+        loginListener.onLogin(loggedInUserAfter.get(), httpRequest,
+                              wrappedResponse);
+      }
+    } else if (loggedInUserBefore.isPresent() && !loggedInUserAfter.isPresent()) {
+      for (WebLoginListener loginListener : webLoginListeners) {
+        loginListener.onLogout(loggedInUserBefore.get(), httpRequest,
+                               wrappedResponse);
+      }
+    }
+
+    wrappedResponse.play();
+  }
+
+  private Optional<IdentifiedUser> loggedInUser() {
+    return session.get().isSignedIn() ?
+             Optional.of(userProvider.get().asIdentifiedUser()) :
+             Optional.empty();
+  }
+
+  @Override
+  public void destroy() {
+  }
+
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
index 2c67182..842c575 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.httpd.raw.CatServlet;
 import com.google.gerrit.httpd.raw.HostPageServlet;
 import com.google.gerrit.httpd.raw.LegacyGerritServlet;
@@ -30,10 +31,10 @@
 import com.google.gerrit.httpd.restapi.GroupsRestApiServlet;
 import com.google.gerrit.httpd.restapi.ProjectsRestApiServlet;
 import com.google.gerrit.httpd.rpc.doc.QueryDocumentationFilter;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gwtexpui.server.CacheControlFilter;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -62,8 +63,9 @@
     filter("/*").through(Key.get(CacheControlFilter.class));
     bind(Key.get(CacheControlFilter.class)).in(SINGLETON);
 
-    if (options.enableDefaultUi()) {
+    if (options.enableGwtUi()) {
       filter("/").through(XsrfCookieFilter.class);
+      filter("/accounts/self/detail").through(XsrfCookieFilter.class);
       serve("/").with(HostPageServlet.class);
       serve("/Gerrit").with(LegacyGerritServlet.class);
       serve("/Gerrit/*").with(legacyGerritScreen());
@@ -90,13 +92,23 @@
     serve("/starred").with(query("is:starred"));
 
     serveRegex("^/settings/?$").with(screen(PageLinks.SETTINGS));
-    serveRegex("^/register/?$").with(screen(PageLinks.REGISTER + "/"));
+    serveRegex("^/register(/.*)?$").with(registerScreen());
     serveRegex("^/([1-9][0-9]*)/?$").with(directChangeById());
     serveRegex("^/p/(.*)$").with(queryProjectNew());
     serveRegex("^/r/(.+)/?$").with(DirectChangeByCommit.class);
 
     filter("/a/*").through(RequireIdentifiedUserFilter.class);
+
+    // Must be after RequireIdentifiedUserFilter so auth happens before checking
+    // for RunAs capability.
+    install(new RunAsFilter.Module());
+
     serveRegex("^/(?:a/)?tools/(.*)$").with(ToolServlet.class);
+
+    // Bind servlets for REST root collections.
+    // The '/plugins/' root collection is already handled by HttpPluginServlet
+    // which is bound in HttpPluginModule. We cannot bind it here again although
+    // this means that plugins can't add REST views on PLUGIN_KIND.
     serveRegex("^/(?:a/)?access/(.*)$").with(AccessRestApiServlet.class);
     serveRegex("^/(?:a/)?accounts/(.*)$").with(AccountsRestApiServlet.class);
     serveRegex("^/(?:a/)?changes/(.*)$").with(ChangesRestApiServlet.class);
@@ -231,6 +243,18 @@
     return srv;
   }
 
+  private Key<HttpServlet> registerScreen() {
+    return key(new HttpServlet() {
+      private static final long serialVersionUID = 1L;
+
+      @Override
+      protected void doGet(final HttpServletRequest req,
+          final HttpServletResponse rsp) throws IOException {
+        toGerrit("/register" + req.getPathInfo(), req, rsp);
+      }
+    });
+  }
+
   static void toGerrit(final String target, final HttpServletRequest req,
       final HttpServletResponse rsp) throws IOException {
     final StringBuilder url = new StringBuilder();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebLoginListener.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebLoginListener.java
new file mode 100644
index 0000000..55e927b
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebLoginListener.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2016 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.httpd;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.IdentifiedUser;
+
+import java.io.IOException;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Allows to listen and override the reponse to login/logout web actions.
+ *
+ * Allows to intercept and act when a Gerrit user logs in or logs out of
+ * the Web interface to perform actions or to override the output response
+ * status code.
+ *
+ * Typical use can be multi-factor authentication (on login) or global sign-out
+ * from SSO systems (on logout).
+ *
+ */
+@ExtensionPoint
+public interface WebLoginListener {
+
+  /**
+   * Invoked after a user's web login.
+   *
+   * @param userId logged in user
+   * @param request request of the latest login action
+   * @param response response of the latest login action
+   */
+  void onLogin(IdentifiedUser userId, HttpServletRequest request,
+               HttpServletResponse response) throws IOException;
+
+  /**
+   * Invoked after a user's web logout.
+   *
+   * @param userId logged out user
+   * @param request request of the latest logout action
+   * @param response response of the latest logout action
+   */
+  void onLogout(IdentifiedUser userId, HttpServletRequest request,
+                HttpServletResponse response) throws IOException;
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
index 3e3b7c4..48ba60e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.git.AsyncReceiveCommits;
@@ -55,8 +56,6 @@
     bind(RequestScopePropagator.class).to(GuiceRequestScopePropagator.class);
     bind(HttpRequestContext.class);
 
-    install(new RunAsFilter.Module());
-
     installAuthModule();
     if (options.enableMasterFeatures()) {
       install(new UrlModule(options, authConfig));
@@ -77,6 +76,8 @@
     bind(ProxyProperties.class).toProvider(ProxyPropertiesProvider.class);
 
     listener().toInstance(registerInParentInjectors());
+
+    install(UniversalWebLoginFilter.module());
   }
 
   private void installAuthModule() {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
index 327aaa3..cddd04f 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.httpd;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.AccessPath;
@@ -22,7 +23,7 @@
 
 public interface WebSession {
   boolean isSignedIn();
-  String getXGerritAuth();
+  @Nullable String getXGerritAuth();
   boolean isValidXGerritAuth(String keyIn);
   AccountExternalId.Key getLastLoginExternalId();
   CurrentUser getUser();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java
index 842b2b4..0c2565c2 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.httpd;
 
+import static com.google.common.base.Strings.nullToEmpty;
+
 import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.CurrentUser;
@@ -61,11 +63,11 @@
 
   private void setXsrfTokenCookie(HttpServletRequest req,
       HttpServletResponse rsp, WebSession session) {
-    String v = session != null ? session.getXGerritAuth() : "";
-    Cookie c = new Cookie(HostPageData.XSRF_COOKIE_NAME, v);
+    String v = session != null ? session.getXGerritAuth() : null;
+    Cookie c = new Cookie(HostPageData.XSRF_COOKIE_NAME, nullToEmpty(v));
     c.setPath("/");
     c.setSecure(authConfig.getCookieSecure() && isSecure(req));
-    c.setMaxAge(session != null
+    c.setMaxAge(v != null
         ? -1 // Set the cookie for this browser session.
         : 0); // Remove the cookie (expire immediately).
     rsp.addCookie(c);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index fe556ac..ac7c7e7 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -609,45 +609,39 @@
       final OutputStream dst) throws IOException {
     final int contentLength = req.getContentLength();
     final InputStream src = req.getInputStream();
-    new Thread(new Runnable() {
-      @Override
-      public void run() {
+    new Thread(() -> {
+      try {
         try {
-          try {
-            final byte[] buf = new byte[bufferSize];
-            int remaining = contentLength;
-            while (0 < remaining) {
-              final int max = Math.max(buf.length, remaining);
-              final int n = src.read(buf, 0, max);
-              if (n < 0) {
-                throw new EOFException("Expected " + remaining + " more bytes");
-              }
-              dst.write(buf, 0, n);
-              remaining -= n;
+          final byte[] buf = new byte[bufferSize];
+          int remaining = contentLength;
+          while (0 < remaining) {
+            final int max = Math.max(buf.length, remaining);
+            final int n = src.read(buf, 0, max);
+            if (n < 0) {
+              throw new EOFException("Expected " + remaining + " more bytes");
             }
-          } finally {
-            dst.close();
+            dst.write(buf, 0, n);
+            remaining -= n;
           }
-        } catch (IOException e) {
-          log.debug("Unexpected error copying input to CGI", e);
+        } finally {
+          dst.close();
         }
+      } catch (IOException e) {
+        log.debug("Unexpected error copying input to CGI", e);
       }
     }, "Gitweb-InputFeeder").start();
   }
 
   private void copyStderrToLog(final InputStream in) {
-    new Thread(new Runnable() {
-      @Override
-      public void run() {
-        try (BufferedReader br =
-            new BufferedReader(new InputStreamReader(in, ISO_8859_1.name()))) {
-          String line;
-          while ((line = br.readLine()) != null) {
-            log.error("CGI: " + line);
-          }
-        } catch (IOException e) {
-          log.debug("Unexpected error copying stderr from CGI", e);
+    new Thread(() -> {
+      try (BufferedReader br =
+          new BufferedReader(new InputStreamReader(in, ISO_8859_1.name()))) {
+        String line;
+        while ((line = br.readLine()) != null) {
+          log.error("CGI: " + line);
         }
+      } catch (IOException e) {
+        log.debug("Unexpected error copying stderr from CGI", e);
       }
     }, "Gitweb-ErrorLogger").start();
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 8594e30..77c8bb4 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -18,14 +18,12 @@
 import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CHARACTER_ENCODING;
 import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CONTENT_TYPE;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.io.ByteStreams;
@@ -73,7 +71,9 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.ConcurrentMap;
+import java.util.function.Predicate;
 import java.util.jar.Attributes;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -359,7 +359,6 @@
           if (Strings.isNullOrEmpty(entryTitle)) {
             entryTitle = rsrc.substring(nameOffset, rsrc.length() - 3).replace('-', ' ');
           }
-          rsrc = rsrc.substring(0, rsrc.length() - 3) + ".html";
         } else {
           entryTitle = rsrc.substring(nameOffset).replace('-', ' ');
         }
@@ -379,34 +378,30 @@
     List<PluginEntry> docs = new ArrayList<>();
     PluginEntry about = null;
 
-    Predicate<PluginEntry> filter = new Predicate<PluginEntry>() {
-      @Override
-      public boolean apply(PluginEntry entry) {
-        String name = entry.getName();
-        Optional<Long> size = entry.getSize();
-        if (name.startsWith(prefix)
-            && (name.endsWith(".md") || name.endsWith(".html"))
-            && size.isPresent()) {
-          if (size.get() <= 0 || size.get() > SMALL_RESOURCE) {
-            log.warn(String.format(
-                "Plugin %s: %s omitted from document index. "
-                  + "Size %d out of range (0,%d).",
-                pluginName,
-                name.substring(prefix.length()),
-                size.get(),
-                SMALL_RESOURCE));
-            return false;
+    Predicate<PluginEntry> filter =
+        entry -> {
+          String name = entry.getName();
+          Optional<Long> size = entry.getSize();
+          if (name.startsWith(prefix)
+              && (name.endsWith(".md") || name.endsWith(".html"))
+              && size.isPresent()) {
+            if (size.get() <= 0 || size.get() > SMALL_RESOURCE) {
+              log.warn(String.format(
+                  "Plugin %s: %s omitted from document index. "
+                    + "Size %d out of range (0,%d).",
+                  pluginName,
+                  name.substring(prefix.length()),
+                  size.get(),
+                  SMALL_RESOURCE));
+              return false;
+            }
+            return true;
           }
-          return true;
-        }
-        return false;
-      }
-    };
+          return false;
+        };
 
-    List<PluginEntry> entries = FluentIterable
-        .from(Collections.list(scanner.entries()))
-        .filter(filter)
-        .toList();
+    List<PluginEntry> entries = Collections.list(scanner.entries()).stream()
+        .filter(filter).collect(toList());
     for (PluginEntry entry: entries) {
       String name = entry.getName().substring(prefix.length());
       if (name.startsWith("cmd-")) {
@@ -561,7 +556,7 @@
       int d = file.lastIndexOf('.');
       return scanner.getEntry(file.substring(0, d) + ".md");
     }
-    return Optional.absent();
+    return Optional.empty();
   }
 
   private void sendMarkdownAsHtml(PluginContentScanner scanner, PluginEntry entry,
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java
index ef55e34..3e0f833 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java
@@ -36,7 +36,7 @@
     } else {
       bowerComponents = GerritLauncher
           .newZipFileSystem(zip)
-          .getPath("bower_components/");
+          .getPath("/");
     }
   }
 
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
index 4047279..42b5e7e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.raw;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Change;
@@ -33,6 +32,7 @@
 import com.google.inject.Singleton;
 
 import java.io.IOException;
+import java.util.Optional;
 
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 4f07ac2..c35738b 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -294,17 +294,14 @@
   }
 
   private Callable<Resource> newLoader(final Path p) {
-    return new Callable<Resource>() {
-      @Override
-      public Resource call() throws IOException {
-        try {
-          return new Resource(
-              getLastModifiedTime(p),
-              contentType(p.toString()),
-              Files.readAllBytes(p));
-        } catch (NoSuchFileException e) {
-          return Resource.NOT_FOUND;
-        }
+    return () -> {
+      try {
+        return new Resource(
+            getLastModifiedTime(p),
+            contentType(p.toString()),
+            Files.readAllBytes(p));
+      } catch (NoSuchFileException e) {
+        return Resource.NOT_FOUND;
       }
     };
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
index 7916ed0..31e337e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -15,16 +15,18 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static java.nio.file.Files.exists;
 import static java.nio.file.Files.isReadable;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.httpd.GerritOptions;
+import com.google.gerrit.extensions.client.UiType;
 import com.google.gerrit.httpd.XsrfCookieFilter;
 import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
@@ -46,8 +48,16 @@
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
 
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
 import javax.servlet.http.HttpServletResponse;
 
 public class StaticModule extends ServletModule {
@@ -55,20 +65,42 @@
       LoggerFactory.getLogger(StaticModule.class);
 
   public static final String CACHE = "static_content";
+  public static final String GERRIT_UI_COOKIE = "GERRIT_UI";
 
+  /**
+   * Paths at which we should serve the main PolyGerrit application {@code
+   * index.html}.
+   * <p>
+   * Supports {@code "/*"} as a trailing wildcard.
+   */
   public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
       ImmutableList.of(
-        "/",
-        "/c/*",
-        "/q/*",
-        "/x/*",
-        "/admin/*",
-        "/dashboard/*",
-        "/settings/*",
-        // TODO(dborowitz): These fragments conflict with the REST API
-        // namespace, so they will need to use a different path.
-        "/groups/*",
-        "/projects/*");
+          "/",
+          "/c/*",
+          "/q/*",
+          "/x/*",
+          "/admin/*",
+          "/dashboard/*",
+          "/settings/*");
+          // TODO(dborowitz): These fragments conflict with the REST API
+          // namespace, so they will need to use a different path.
+          //"/groups/*",
+          //"/projects/*");
+          //
+
+  /**
+   * Paths that should be treated as static assets when serving PolyGerrit.
+   * <p>
+   * Supports {@code "/*"} as a trailing wildcard.
+   */
+  private static final ImmutableList<String> POLYGERRIT_ASSET_PATHS =
+      ImmutableList.of(
+          "/behaviors/*",
+          "/bower_components/*",
+          "/elements/*",
+          "/fonts/*",
+          "/scripts/*",
+          "/styles/*");
 
   private static final String DOC_SERVLET = "DocServlet";
   private static final String FAVICON_SERVLET = "FaviconServlet";
@@ -77,6 +109,8 @@
       "PolyGerritUiIndexServlet";
   private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
 
+  private static final int GERRIT_UI_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
+
   private final GerritOptions options;
   private Paths paths;
 
@@ -85,9 +119,11 @@
     this.options = options;
   }
 
+  @Provides
+  @Singleton
   private Paths getPaths() {
     if (paths == null) {
-      paths = new Paths();
+      paths = new Paths(options);
     }
     return paths;
   }
@@ -104,11 +140,13 @@
             .weigher(ResourceServlet.Weigher.class);
       }
     });
+    if (!options.headless()) {
+      install(new CoreStaticModule());
+    }
     if (options.enablePolyGerrit()) {
-      install(new CoreStaticModule());
-      install(new PolyGerritUiModule());
-    } else if (options.enableDefaultUi()) {
-      install(new CoreStaticModule());
+      install(new PolyGerritModule());
+    }
+    if (options.enableGwtUi()) {
       install(new GwtUiModule());
     }
   }
@@ -211,25 +249,17 @@
     }
   }
 
-  private class PolyGerritUiModule extends ServletModule {
+  private class PolyGerritModule extends ServletModule {
     @Override
     public void configureServlets() {
-      Path buckOut = getPaths().buckOut;
-      if (buckOut != null) {
-        serve("/bower_components/*").with(BowerComponentsServlet.class);
-        serve("/fonts/*").with(FontsServlet.class);
-      } else {
-        // In the war case, bower_components and fonts are either inlined
-        // by vulcanize, or live under /polygerrit_ui in the war file,
-        // so we don't need a separate servlet.
-      }
-
-      Key<HttpServlet> indexKey = named(POLYGERRIT_INDEX_SERVLET);
       for (String p : POLYGERRIT_INDEX_PATHS) {
-        filter(p).through(XsrfCookieFilter.class);
-        serve(p).with(indexKey);
+        // Skip XsrfCookieFilter for /, since that is already done in the GWT UI
+        // path (UrlModule).
+        if (!p.equals("/")) {
+          filter(p).through(XsrfCookieFilter.class);
+        }
       }
-      serve("/*").with(PolyGerritUiServlet.class);
+      filter("/*").through(PolyGerritFilter.class);
     }
 
     @Provides
@@ -281,13 +311,13 @@
     }
   }
 
-  private class Paths {
+  private static class Paths {
     private final FileSystem warFs;
     private final Path buckOut;
     private final Path unpackedWar;
     private final boolean development;
 
-    private Paths() {
+    private Paths(GerritOptions options) {
       try {
         File launcherLoadedFrom = getLauncherLoadedFrom();
         if (launcherLoadedFrom != null
@@ -393,4 +423,200 @@
   private static Key<HttpServlet> named(String name) {
     return Key.get(HttpServlet.class, Names.named(name));
   }
+
+  @Singleton
+  private static class PolyGerritFilter implements Filter {
+    private final GerritOptions options;
+    private final Paths paths;
+    private final HttpServlet polyGerritIndex;
+    private final PolyGerritUiServlet polygerritUI;
+    private final BowerComponentsServlet bowerComponentServlet;
+    private final FontsServlet fontServlet;
+
+    @Inject
+    PolyGerritFilter(GerritOptions options,
+        Paths paths,
+        @Named(POLYGERRIT_INDEX_SERVLET) HttpServlet polyGerritIndex,
+        PolyGerritUiServlet polygerritUI,
+        BowerComponentsServlet bowerComponentServlet,
+        FontsServlet fontServlet) {
+      this.paths = paths;
+      this.options = options;
+      this.polyGerritIndex = polyGerritIndex;
+      this.polygerritUI = polygerritUI;
+      this.bowerComponentServlet = bowerComponentServlet;
+      this.fontServlet = fontServlet;
+      checkState(options.enablePolyGerrit(),
+          "can't install PolyGerritFilter when PolyGerrit is disabled");
+    }
+
+    @Override
+    public void init(FilterConfig filterConfig) throws ServletException {
+    }
+
+    @Override
+    public void destroy() {
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response,
+        FilterChain chain) throws IOException, ServletException {
+      HttpServletRequest req = (HttpServletRequest) request;
+      HttpServletResponse res = (HttpServletResponse) response;
+      if (handlePolyGerritParam(req, res)) {
+        return;
+      }
+      if (!isPolyGerritEnabled(req)) {
+        chain.doFilter(req, res);
+        return;
+      }
+
+      GuiceFilterRequestWrapper reqWrapper =
+          new GuiceFilterRequestWrapper(req);
+      String path = pathInfo(req);
+
+      // Special case assets during development that are built by Buck and not
+      // served out of the source tree.
+      //
+      // In the war case, these are either inlined by vulcanize, or live under
+      // /polygerrit_ui in the war file, so we can just treat them as normal
+      // assets.
+      if (paths.isDev()) {
+        if (path.startsWith("/bower_components/")) {
+          bowerComponentServlet.service(reqWrapper, res);
+          return;
+        } else if (path.startsWith("/fonts/")) {
+          fontServlet.service(reqWrapper, res);
+          return;
+        }
+      }
+
+      if (isPolyGerritIndex(path)) {
+        polyGerritIndex.service(reqWrapper, res);
+        return;
+      }
+      if (isPolyGerritAsset(path)) {
+        polygerritUI.service(reqWrapper, res);
+        return;
+      }
+
+      chain.doFilter(req, res);
+    }
+
+    private static String pathInfo(HttpServletRequest req) {
+      String uri = req.getRequestURI();
+      String ctx = req.getContextPath();
+      return uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
+    }
+
+    private boolean handlePolyGerritParam(HttpServletRequest req,
+        HttpServletResponse res) throws IOException {
+      if (!options.enableGwtUi()) {
+        return false;
+      }
+      boolean redirect = false;
+      String param = req.getParameter("polygerrit");
+      if ("1".equals(param)) {
+        setPolyGerritCookie(req, res, UiType.POLYGERRIT);
+        redirect = true;
+      } else if ("0".equals(param)) {
+        setPolyGerritCookie(req, res, UiType.GWT);
+        redirect = true;
+      }
+      if (redirect) {
+        // Strip polygerrit param from URL. This actually strips all params,
+        // which is a similar behavior to the JS PolyGerrit redirector code.
+        // Stripping just one param is frustratingly difficult without the use
+        // of Apache httpclient, which is a dep we don't want here:
+        // https://gerrit-review.googlesource.com/#/c/57570/57/gerrit-httpd/BUCK@32
+        res.sendRedirect(req.getRequestURL().toString());
+      }
+      return redirect;
+    }
+
+    private boolean isPolyGerritEnabled(HttpServletRequest req) {
+      return !options.enableGwtUi() || isPolyGerritCookie(req);
+    }
+
+    private boolean isPolyGerritCookie(HttpServletRequest req) {
+      UiType type = options.defaultUi();
+      Cookie[] all = req.getCookies();
+      if (all != null) {
+        for (Cookie c : all) {
+          if (GERRIT_UI_COOKIE.equals(c.getName())) {
+            UiType t = UiType.parse(c.getValue());
+            if (t != null) {
+              type = t;
+              break;
+            }
+          }
+        }
+      }
+      return type == UiType.POLYGERRIT;
+    }
+
+    private void setPolyGerritCookie(HttpServletRequest req,
+        HttpServletResponse res, UiType pref) {
+      // Only actually set a cookie if both UIs are enabled in the server;
+      // otherwise clear it.
+      Cookie cookie = new Cookie(GERRIT_UI_COOKIE, pref.name());
+      if (options.enablePolyGerrit() && options.enableGwtUi()) {
+        cookie.setPath("/");
+        cookie.setSecure(isSecure(req));
+        cookie.setMaxAge(GERRIT_UI_COOKIE_MAX_AGE);
+      } else {
+        cookie.setValue("");
+        cookie.setMaxAge(0);
+      }
+      res.addCookie(cookie);
+    }
+
+    private static boolean isSecure(HttpServletRequest req) {
+      return req.isSecure() || "https".equals(req.getScheme());
+    }
+
+    private static boolean isPolyGerritAsset(String path) {
+      return matchPath(POLYGERRIT_ASSET_PATHS, path);
+    }
+
+    private static boolean isPolyGerritIndex(String path) {
+      return matchPath(POLYGERRIT_INDEX_PATHS, path);
+    }
+
+    private static boolean matchPath(Iterable<String> paths, String path) {
+      for (String p : paths) {
+        if (p.endsWith("/*")) {
+          if (path.regionMatches(0, p, 0, p.length() - 1)) {
+            return true;
+          }
+        } else if(p.equals(path)) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+
+  private static class GuiceFilterRequestWrapper
+      extends HttpServletRequestWrapper {
+    GuiceFilterRequestWrapper(HttpServletRequest req) {
+      super(req);
+    }
+
+    @Override
+    public String getPathInfo() {
+      String uri = getRequestURI();
+      String ctx = getContextPath();
+      // This is a workaround for long standing guice filter bug:
+      // https://github.com/google/guice/issues/807
+      String res = uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
+
+      // Match the logic in the ResourceServlet, that re-add "/"
+      // for null path info
+      if ("/".equals(res)) {
+        return null;
+      }
+      return res;
+    }
+  }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 943d824..c1a3eec 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -15,9 +15,18 @@
 package com.google.gerrit.httpd.restapi;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.net.HttpHeaders.VARY;
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
@@ -33,11 +42,12 @@
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
 
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
@@ -85,6 +95,7 @@
 import com.google.gerrit.server.OptionUtil;
 import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.account.CapabilityUtils;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.util.http.RequestUtil;
 import com.google.gson.ExclusionStrategy;
 import com.google.gson.FieldAttributes;
@@ -103,6 +114,7 @@
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
 
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.TemporaryBuffer;
 import org.eclipse.jgit.util.TemporaryBuffer.Heap;
 import org.slf4j.Logger;
@@ -131,6 +143,8 @@
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+import java.util.stream.StreamSupport;
 import java.util.zip.GZIPOutputStream;
 
 import javax.servlet.ServletException;
@@ -150,6 +164,9 @@
   // HTTP 422 Unprocessable Entity.
   // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
   private static final int SC_UNPROCESSABLE_ENTITY = 422;
+  private static final String X_REQUESTED_WITH = "X-Requested-With";
+  private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
+      ImmutableSet.of(X_REQUESTED_WITH);
 
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
 
@@ -174,18 +191,29 @@
     final Provider<ParameterParser> paramParser;
     final AuditService auditService;
     final RestApiMetrics metrics;
+    final Pattern allowOrigin;
 
     @Inject
     Globals(Provider<CurrentUser> currentUser,
         DynamicItem<WebSession> webSession,
         Provider<ParameterParser> paramParser,
         AuditService auditService,
-        RestApiMetrics metrics) {
+        RestApiMetrics metrics,
+        @GerritServerConfig Config cfg) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
       this.auditService = auditService;
       this.metrics = metrics;
+      allowOrigin = makeAllowOrigin(cfg);
+    }
+
+    private static Pattern makeAllowOrigin(Config cfg) {
+      String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
+      if (allow.length > 0) {
+        return Pattern.compile(Joiner.on('|').join(allow));
+      }
+      return null;
     }
   }
 
@@ -222,6 +250,11 @@
     ViewData viewData = null;
 
     try {
+      if (isCorsPreflight(req)) {
+        doCorsPreflight(req, res);
+        return;
+      }
+      checkCors(req, res);
       checkUserSession(req);
 
       List<IdString> path = splitPath(req);
@@ -232,7 +265,7 @@
       viewData = new ViewData(null, null);
 
       if (path.isEmpty()) {
-        if (isGetOrHead(req)) {
+        if (isRead(req)) {
           viewData = new ViewData(null, rc.list());
         } else if (rc instanceof AcceptsPost && "POST".equals(req.getMethod())) {
           @SuppressWarnings("unchecked")
@@ -273,7 +306,7 @@
             (RestCollection<RestResource, RestResource>) viewData.view;
 
         if (path.isEmpty()) {
-          if (isGetOrHead(req)) {
+          if (isRead(req)) {
             viewData = new ViewData(null, c.list());
           } else if (c instanceof AcceptsPost && "POST".equals(req.getMethod())) {
             @SuppressWarnings("unchecked")
@@ -330,7 +363,7 @@
         return;
       }
 
-      if (viewData.view instanceof RestReadView<?> && isGetOrHead(req)) {
+      if (viewData.view instanceof RestReadView<?> && isRead(req)) {
         result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
       } else if (viewData.view instanceof RestModifyView<?, ?>) {
         @SuppressWarnings("unchecked")
@@ -428,6 +461,74 @@
     }
   }
 
+  private void checkCors(HttpServletRequest req, HttpServletResponse res) {
+    String origin = req.getHeader(ORIGIN);
+    if (isRead(req)
+        && !Strings.isNullOrEmpty(origin)
+        && isOriginAllowed(origin)) {
+      res.addHeader(VARY, ORIGIN);
+      setCorsHeaders(res, origin);
+    }
+  }
+
+  private static boolean isCorsPreflight(HttpServletRequest req) {
+    return "OPTIONS".equals(req.getMethod())
+        && !Strings.isNullOrEmpty(req.getHeader(ORIGIN))
+        && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD));
+  }
+
+  private void doCorsPreflight(HttpServletRequest req,
+      HttpServletResponse res) throws BadRequestException {
+    CacheHeaders.setNotCacheable(res);
+    res.setHeader(VARY, Joiner.on(", ").join(ImmutableList.of(
+        ORIGIN,
+        ACCESS_CONTROL_REQUEST_METHOD)));
+
+    String origin = req.getHeader(ORIGIN);
+    if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+      throw new BadRequestException("CORS not allowed");
+    }
+
+    String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
+    if (!"GET".equals(method) && !"HEAD".equals(method)) {
+      throw new BadRequestException(method + " not allowed in CORS");
+    }
+
+    String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
+    if (headers != null) {
+      res.addHeader(VARY, ACCESS_CONTROL_REQUEST_HEADERS);
+      String badHeader =
+          StreamSupport.stream(
+                  Splitter.on(',').trimResults().split(headers).spliterator(),
+                  false)
+              .filter(h -> !ALLOWED_CORS_REQUEST_HEADERS.contains(h))
+              .findFirst()
+              .orElse(null);
+      if (badHeader != null) {
+        throw new BadRequestException(badHeader + " not allowed in CORS");
+      }
+    }
+
+    res.setStatus(SC_OK);
+    setCorsHeaders(res, origin);
+    res.setContentType("text/plain");
+    res.setContentLength(0);
+  }
+
+  private void setCorsHeaders(HttpServletResponse res, String origin) {
+    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    res.setHeader(ACCESS_CONTROL_ALLOW_METHODS, "GET, OPTIONS");
+    res.setHeader(
+        ACCESS_CONTROL_ALLOW_HEADERS,
+        Joiner.on(", ").join(ALLOWED_CORS_REQUEST_HEADERS));
+  }
+
+  private boolean isOriginAllowed(String origin) {
+    return globals.allowOrigin != null
+        && globals.allowOrigin.matcher(origin).matches();
+  }
+
   private static String messageOr(Throwable t, String defaultMessage) {
     if (!Strings.isNullOrEmpty(t.getMessage())) {
       return t.getMessage();
@@ -438,7 +539,7 @@
   @SuppressWarnings({"unchecked", "rawtypes"})
   private static boolean notModified(HttpServletRequest req, RestResource rsrc,
       RestView<RestResource> view) {
-    if (!isGetOrHead(req)) {
+    if (!isRead(req)) {
       return false;
     }
 
@@ -469,7 +570,7 @@
   private static <R extends RestResource> void configureCaching(
       HttpServletRequest req, HttpServletResponse res, R rsrc,
       RestView<R> view, CacheControl c) {
-    if (isGetOrHead(req)) {
+    if (isRead(req)) {
       switch (c.getType()) {
         case NONE:
         default:
@@ -935,16 +1036,12 @@
     } else if (r.isEmpty()) {
       throw new ResourceNotFoundException(projection);
     } else {
-      throw new AmbiguousViewException(String.format(
-        "Projection %s is ambiguous: %s",
-        name,
-        Joiner.on(", ").join(
-          Iterables.transform(r.keySet(), new Function<String, String>() {
-            @Override
-            public String apply(String in) {
-              return in + "~" + projection;
-            }
-          }))));
+      throw new AmbiguousViewException(
+          String.format(
+              "Projection %s is ambiguous: %s",
+              name,
+              r.keySet().stream().map(in -> in + "~" + projection)
+                  .collect(joining(", "))));
     }
   }
 
@@ -972,25 +1069,20 @@
   private void checkUserSession(HttpServletRequest req)
       throws AuthException {
     CurrentUser user = globals.currentUser.get();
-    if (isStateChange(req)) {
-      if (user instanceof AnonymousUser) {
-        throw new AuthException("Authentication required");
-      } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
-        throw new AuthException("Invalid authentication method. In order to authenticate, "
-            + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
-      }
+    if (isRead(req)) {
+      user.setAccessPath(AccessPath.REST_API);
+    } else if (user instanceof AnonymousUser) {
+      throw new AuthException("Authentication required");
+    } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
+      throw new AuthException("Invalid authentication method. In order to authenticate, "
+          + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
     }
-    user.setAccessPath(AccessPath.REST_API);
   }
 
-  private static boolean isGetOrHead(HttpServletRequest req) {
+  private static boolean isRead(HttpServletRequest req) {
     return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
   }
 
-  private static boolean isStateChange(HttpServletRequest req) {
-    return !isGetOrHead(req);
-  }
-
   private void checkRequiresCapability(ViewData viewData) throws AuthException {
     CapabilityUtils.checkRequiresCapability(globals.currentUser,
         viewData.pluginName, viewData.view.getClass());
@@ -1029,7 +1121,7 @@
 
   static long replyText(@Nullable HttpServletRequest req,
       HttpServletResponse res, String text) throws IOException {
-    if ((req == null || isGetOrHead(req)) && isMaybeHTML(text)) {
+    if ((req == null || isRead(req)) && isMaybeHTML(text)) {
       return replyJson(req, res, ImmutableMultimap.of("pp", "0"), new JsonPrimitive(text));
     }
     if (!text.endsWith("\n")) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
index c0fb86b..bda2d91 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
@@ -14,11 +14,8 @@
 
 package com.google.gerrit.httpd.rpc;
 
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.SshHostKey;
 import com.google.gerrit.common.data.SystemInfoService;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwtjsonrpc.common.VoidResult;
@@ -32,7 +29,6 @@
 import org.slf4j.LoggerFactory;
 
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 
 import javax.servlet.http.HttpServletRequest;
@@ -45,28 +41,12 @@
 
   private final List<HostKey> hostKeys;
   private final Provider<HttpServletRequest> httpRequest;
-  private final ProjectCache projectCache;
 
   @Inject
   SystemInfoServiceImpl(SshInfo daemon,
-      Provider<HttpServletRequest> hsr,
-      ProjectCache pc) {
+      Provider<HttpServletRequest> hsr) {
     hostKeys = daemon.getHostKeys();
     httpRequest = hsr;
-    projectCache = pc;
-  }
-
-  @Override
-  public void contributorAgreements(
-      final AsyncCallback<List<ContributorAgreement>> callback) {
-    Collection<ContributorAgreement> agreements =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
-    List<ContributorAgreement> cas =
-        Lists.newArrayListWithCapacity(agreements.size());
-    for (ContributorAgreement ca : agreements) {
-      cas.add(ca.forUi());
-    }
-    callback.onSuccess(cas);
   }
 
   @Override
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountModule.java
index 62778eb..d32fdaf 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountModule.java
@@ -28,12 +28,10 @@
     install(new FactoryModule() {
       @Override
       protected void configure() {
-        factory(AgreementInfoFactory.Factory.class);
         factory(DeleteExternalIds.Factory.class);
         factory(ExternalIdDetailFactory.Factory.class);
       }
     });
     rpc(AccountSecurityImpl.class);
-    rpc(AccountServiceImpl.class);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java
index 8fcf9ea..3d05548 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java
@@ -14,74 +14,31 @@
 
 package com.google.gerrit.httpd.rpc.account;
 
-import com.google.common.base.Strings;
-import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.data.AccountSecurity;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.errors.NoSuchEntityException;
-import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.httpd.rpc.BaseServiceImplementation;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountByEmailCache;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.Realm;
-import com.google.gerrit.server.extensions.events.AgreementSignup;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.VoidResult;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
-import java.io.IOException;
-import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 
 class AccountSecurityImpl extends BaseServiceImplementation implements
     AccountSecurity {
-  private final Realm realm;
-  private final ProjectCache projectCache;
-  private final Provider<IdentifiedUser> user;
-  private final AccountByEmailCache byEmailCache;
-  private final AccountCache accountCache;
-
   private final DeleteExternalIds.Factory deleteExternalIdsFactory;
   private final ExternalIdDetailFactory.Factory externalIdDetailFactory;
 
-  private final GroupCache groupCache;
-  private final AuditService auditService;
-  private final AgreementSignup agreementSignup;
-
   @Inject
   AccountSecurityImpl(final Provider<ReviewDb> schema,
       final Provider<CurrentUser> currentUser,
-      final Realm r, final Provider<IdentifiedUser> u,
-      final ProjectCache pc,
-      final AccountByEmailCache abec, final AccountCache uac,
       final DeleteExternalIds.Factory deleteExternalIdsFactory,
-      final ExternalIdDetailFactory.Factory externalIdDetailFactory,
-      final GroupCache groupCache,
-      final AuditService auditService,
-      AgreementSignup agreementSignup) {
+      final ExternalIdDetailFactory.Factory externalIdDetailFactory) {
     super(schema, currentUser);
-    realm = r;
-    user = u;
-    projectCache = pc;
-    byEmailCache = abec;
-    accountCache = uac;
-    this.auditService = auditService;
     this.deleteExternalIdsFactory = deleteExternalIdsFactory;
     this.externalIdDetailFactory = externalIdDetailFactory;
-    this.groupCache = groupCache;
-    this.agreementSignup = agreementSignup;
   }
 
   @Override
@@ -94,84 +51,4 @@
       final AsyncCallback<Set<AccountExternalId.Key>> callback) {
     deleteExternalIdsFactory.create(keys).to(callback);
   }
-
-  @Override
-  public void updateContact(final String name, final String emailAddr,
-      final AsyncCallback<Account> callback) {
-    run(callback, new Action<Account>() {
-      @Override
-      public Account run(ReviewDb db)
-          throws OrmException, Failure, IOException {
-        IdentifiedUser self = user.get();
-        final Account me = db.accounts().get(self.getAccountId());
-        final String oldEmail = me.getPreferredEmail();
-        if (realm.allowsEdit(Account.FieldName.FULL_NAME)) {
-          me.setFullName(Strings.emptyToNull(name));
-        }
-        if (!Strings.isNullOrEmpty(emailAddr)
-            && !self.hasEmailAddress(emailAddr)) {
-          throw new Failure(new PermissionDeniedException("Email address must be verified"));
-        }
-        me.setPreferredEmail(Strings.emptyToNull(emailAddr));
-        db.accounts().update(Collections.singleton(me));
-        if (!eq(oldEmail, me.getPreferredEmail())) {
-          byEmailCache.evict(oldEmail);
-          byEmailCache.evict(me.getPreferredEmail());
-        }
-        accountCache.evict(me.getId());
-        return me;
-      }
-    });
-  }
-
-  private static boolean eq(final String a, final String b) {
-    if (a == null && b == null) {
-      return true;
-    }
-    return a != null && a.equals(b);
-  }
-
-  @Override
-  public void enterAgreement(final String agreementName,
-      final AsyncCallback<VoidResult> callback) {
-    run(callback, new Action<VoidResult>() {
-      @Override
-      public VoidResult run(final ReviewDb db)
-          throws OrmException, Failure, IOException {
-        ContributorAgreement ca = projectCache.getAllProjects().getConfig()
-            .getContributorAgreement(agreementName);
-        if (ca == null) {
-          throw new Failure(new NoSuchEntityException());
-        }
-
-        if (ca.getAutoVerify() == null) {
-          throw new Failure(new IllegalStateException(
-              "cannot enter a non-autoVerify agreement"));
-        } else if (ca.getAutoVerify().getUUID() == null) {
-          throw new Failure(new NoSuchEntityException());
-        }
-
-        AccountGroup group = groupCache.get(ca.getAutoVerify().getUUID());
-        if (group == null) {
-          throw new Failure(new NoSuchEntityException());
-        }
-
-        Account account = user.get().getAccount();
-        agreementSignup.fire(account, ca.getName());
-
-        final AccountGroupMember.Key key =
-            new AccountGroupMember.Key(account.getId(), group.getId());
-        AccountGroupMember m = db.accountGroupMembers().get(key);
-        if (m == null) {
-          m = new AccountGroupMember(key);
-          auditService.dispatchAddAccountsToGroup(account.getId(), Collections
-              .singleton(m));
-          db.accountGroupMembers().insert(Collections.singleton(m));
-          accountCache.evict(m.getAccountId());
-        }
-
-        return VoidResult.INSTANCE;
-      }
-    });
-  }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountServiceImpl.java
deleted file mode 100644
index 8fba47d..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountServiceImpl.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2008 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.httpd.rpc.account;
-
-import com.google.gerrit.common.data.AccountService;
-import com.google.gerrit.common.data.AgreementInfo;
-import com.google.gerrit.httpd.rpc.BaseServiceImplementation;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-class AccountServiceImpl extends BaseServiceImplementation implements
-    AccountService {
-  private final AgreementInfoFactory.Factory agreementInfoFactory;
-
-  @Inject
-  AccountServiceImpl(final Provider<ReviewDb> schema,
-      final Provider<IdentifiedUser> identifiedUser,
-      final AgreementInfoFactory.Factory agreementInfoFactory) {
-    super(schema, identifiedUser);
-    this.agreementInfoFactory = agreementInfoFactory;
-  }
-
-  @Override
-  public void myAgreements(final AsyncCallback<AgreementInfo> callback) {
-    agreementInfoFactory.create().to(callback);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AgreementInfoFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AgreementInfoFactory.java
deleted file mode 100644
index 91afd97..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AgreementInfoFactory.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc.account;
-
-import com.google.gerrit.common.data.AgreementInfo;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.httpd.rpc.Handler;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.inject.Inject;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-class AgreementInfoFactory extends Handler<AgreementInfo> {
-  private static final Logger log = LoggerFactory.getLogger(AgreementInfoFactory.class);
-
-  interface Factory {
-    AgreementInfoFactory create();
-  }
-
-  private final IdentifiedUser user;
-  private final ProjectCache projectCache;
-
-  private AgreementInfo info;
-
-  @Inject
-  AgreementInfoFactory(final IdentifiedUser user,
-      final ProjectCache projectCache) {
-    this.user = user;
-    this.projectCache = projectCache;
-  }
-
-  @Override
-  public AgreementInfo call() throws Exception {
-    List<String> accepted = new ArrayList<>();
-    Map<String, ContributorAgreement> agreements = new HashMap<>();
-    Collection<ContributorAgreement> cas =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
-    for (ContributorAgreement ca : cas) {
-      agreements.put(ca.getName(), ca.forUi());
-
-      List<AccountGroup.UUID> groupIds = new ArrayList<>();
-      for (PermissionRule rule : ca.getAccepted()) {
-        if ((rule.getAction() == Action.ALLOW) && (rule.getGroup() != null)) {
-          if (rule.getGroup().getUUID() == null) {
-            log.warn("group \"" + rule.getGroup().getName() + "\" does not " +
-                " exist, referenced in CLA \"" + ca.getName() + "\"");
-          } else {
-            groupIds.add(new AccountGroup.UUID(rule.getGroup().getUUID().get()));
-          }
-        }
-      }
-      if (user.getEffectiveGroups().containsAnyOf(groupIds)) {
-        accepted.add(ca.getName());
-      }
-    }
-
-    info = new AgreementInfo();
-    info.setAccepted(accepted);
-    info.setAgreements(agreements);
-    return info;
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
index ed2a4f9..bd88e6a 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.rpc.project;
 
-import com.google.common.base.Predicate;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.AccessSection;
@@ -238,14 +237,7 @@
         }
       }
     }
-    return Maps.filterEntries(
-      infos,
-      new Predicate<Map.Entry<AccountGroup.UUID, GroupInfo>>() {
-        @Override
-        public boolean apply(Map.Entry<AccountGroup.UUID, GroupInfo> in) {
-          return in.getValue() != null;
-        }
-      });
+    return Maps.filterEntries(infos, in -> in.getValue() != null);
   }
 
   private ProjectControl open() throws NoSuchProjectException {
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
index 94f3768..6ccfc38 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
@@ -17,7 +17,6 @@
 import static org.easymock.EasyMock.anyObject;
 import static org.easymock.EasyMock.capture;
 import static org.easymock.EasyMock.eq;
-import static org.easymock.EasyMock.newCapture;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.ReloadableRegistrationHandle;
@@ -145,7 +144,7 @@
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock(FilterChain.class);
 
-    Capture<FilterChain> capturedChain = newCapture();
+    Capture<FilterChain> capturedChain = new Capture<>();
 
     AllRequestFilter filter = mockControl.createMock(AllRequestFilter.class);
     filter.init(config);
@@ -210,8 +209,8 @@
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock(FilterChain.class);
 
-    Capture<FilterChain> capturedChainA = newCapture();
-    Capture<FilterChain> capturedChainB = newCapture();
+    Capture<FilterChain> capturedChainA = new Capture<>();
+    Capture<FilterChain> capturedChainB = new Capture<>();
 
     AllRequestFilter filterA = mockControl.createMock(AllRequestFilter.class);
     AllRequestFilter filterB = mockControl.createMock(AllRequestFilter.class);
@@ -252,9 +251,9 @@
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock("chain", FilterChain.class);
 
-    Capture<FilterChain> capturedChainA1 = newCapture();
-    Capture<FilterChain> capturedChainA2 = newCapture();
-    Capture<FilterChain> capturedChainB = newCapture();
+    Capture<FilterChain> capturedChainA1 = new Capture<>();
+    Capture<FilterChain> capturedChainA2 = new Capture<>();
+    Capture<FilterChain> capturedChainB = new Capture<>();
 
     AllRequestFilter filterA = mockControl.createMock("filterA", AllRequestFilter.class);
     AllRequestFilter filterB = mockControl.createMock("filterB", AllRequestFilter.class);
@@ -308,9 +307,9 @@
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock("chain", FilterChain.class);
 
-    Capture<FilterChain> capturedChainA1 = newCapture();
-    Capture<FilterChain> capturedChainB1 = newCapture();
-    Capture<FilterChain> capturedChainB2 = newCapture();
+    Capture<FilterChain> capturedChainA1 = new Capture<>();
+    Capture<FilterChain> capturedChainB1 = new Capture<>();
+    Capture<FilterChain> capturedChainB2 = new Capture<>();
 
     AllRequestFilter filterA = mockControl.createMock("filterA", AllRequestFilter.class);
     AllRequestFilter filterB = mockControl.createMock("filterB", AllRequestFilter.class);
diff --git a/gerrit-index/BUCK b/gerrit-index/BUCK
new file mode 100644
index 0000000..ea97f88
--- /dev/null
+++ b/gerrit-index/BUCK
@@ -0,0 +1,13 @@
+java_library(
+  name = 'index',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = [
+    '//gerrit-extension-api:api',
+    '//gerrit-server:server',
+    '//gerrit-patch-jgit:server',
+    '//lib/guice:guice',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib:guava',
+  ],
+  visibility = ['PUBLIC'],
+)
diff --git a/gerrit-index/BUILD b/gerrit-index/BUILD
new file mode 100644
index 0000000..119d5c4
--- /dev/null
+++ b/gerrit-index/BUILD
@@ -0,0 +1,13 @@
+java_library(
+  name = 'index',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = [
+    '//gerrit-extension-api:api',
+    '//gerrit-server:server',
+    '//gerrit-patch-jgit:server',
+    '//lib/guice:guice',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib:guava',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java b/gerrit-index/src/main/java/com/google/gerrit/index/GerritIndexStatus.java
similarity index 84%
rename from gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java
rename to gerrit-index/src/main/java/com/google/gerrit/index/GerritIndexStatus.java
index f43e385..cafd30e 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/GerritIndexStatus.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.lucene;
+package com.google.gerrit.index;
 
 import com.google.common.primitives.Ints;
 import com.google.gerrit.server.config.SitePaths;
@@ -24,13 +24,13 @@
 
 import java.io.IOException;
 
-class GerritIndexStatus {
+public class GerritIndexStatus {
   private static final String SECTION = "index";
   private static final String KEY_READY = "ready";
 
   private final FileBasedConfig cfg;
 
-  GerritIndexStatus(SitePaths sitePaths)
+  public GerritIndexStatus(SitePaths sitePaths)
       throws ConfigInvalidException, IOException {
     cfg = new FileBasedConfig(
         sitePaths.index_dir.resolve("gerrit_index.config").toFile(),
@@ -39,16 +39,16 @@
     convertLegacyConfig();
   }
 
-  void setReady(String indexName, int version, boolean ready) {
+  public void setReady(String indexName, int version, boolean ready) {
     cfg.setBoolean(SECTION, indexDirName(indexName, version), KEY_READY, ready);
   }
 
-  boolean getReady(String indexName, int version) {
+  public boolean getReady(String indexName, int version) {
     return cfg.getBoolean(SECTION, indexDirName(indexName, version), KEY_READY,
         false);
   }
 
-  void save() throws IOException {
+  public void save() throws IOException {
     cfg.save();
   }
 
@@ -62,8 +62,8 @@
         if (ready != null) {
           dirty = false;
           cfg.unset(SECTION, subsection, KEY_READY);
-          cfg.setString(SECTION,
-              indexDirName(ChangeSchemaDefinitions.NAME, v), KEY_READY, ready);
+          cfg.setString(SECTION, indexDirName(ChangeSchemaDefinitions.NAME, v),
+              KEY_READY, ready);
         }
       }
     }
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexUtils.java b/gerrit-index/src/main/java/com/google/gerrit/index/IndexUtils.java
new file mode 100644
index 0000000..f00f5c2
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/IndexUtils.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2013 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.index;
+
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
+import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.QueryOptions;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Set;
+
+public final class IndexUtils {
+  public static final Map<String, String> CUSTOM_CHAR_MAPPING =
+      ImmutableMap.of("_", " ", ".", " ");
+
+  public static void setReady(SitePaths sitePaths, String name, int version,
+      boolean ready) throws IOException {
+    try {
+      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
+      cfg.setReady(name, version, ready);
+      cfg.save();
+    } catch (ConfigInvalidException e) {
+      throw new IOException(e);
+    }
+  }
+
+  public static Set<String> fields(QueryOptions opts) {
+    // Ensure we request enough fields to construct a ChangeData. We need both
+    // change ID and project, which can either come via the Change field or
+    // separate fields.
+    Set<String> fs = opts.fields();
+    if (fs.contains(CHANGE.getName())) {
+      // A Change is always sufficient.
+      return fs;
+    }
+    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID.getName())) {
+      return fs;
+    }
+    return Sets.union(fs,
+        ImmutableSet.of(LEGACY_ID.getName(), PROJECT.getName()));
+  }
+
+  private IndexUtils() {
+    // hide default constructor
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/SingleVersionModule.java b/gerrit-index/src/main/java/com/google/gerrit/index/SingleVersionModule.java
new file mode 100644
index 0000000..d547b06
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/SingleVersionModule.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2013 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.index;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.index.Schema;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import com.google.inject.name.Names;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+@Singleton
+public class SingleVersionModule extends LifecycleModule {
+  static final String SINGLE_VERSIONS = "IndexModule/SingleVersions";
+
+  private final Map<String, Integer> singleVersions;
+
+  public SingleVersionModule(Map<String, Integer> singleVersions) {
+    this.singleVersions = singleVersions;
+  }
+
+  @Override
+  public void configure() {
+    listener().to(SingleVersionListener.class);
+    bind(new TypeLiteral<Map<String, Integer>>() {})
+        .annotatedWith(Names.named(SINGLE_VERSIONS))
+        .toInstance(singleVersions);
+  }
+
+  @Singleton
+  static class SingleVersionListener implements LifecycleListener {
+    private final Set<String> disabled;
+    private final Collection<IndexDefinition<?, ?, ?>> defs;
+    private final Map<String, Integer> singleVersions;
+
+    @Inject
+    SingleVersionListener(
+        @GerritServerConfig Config cfg,
+        Collection<IndexDefinition<?, ?, ?>> defs,
+        @Named(SINGLE_VERSIONS) Map<String, Integer> singleVersions) {
+      this.defs = defs;
+      this.singleVersions = singleVersions;
+
+      disabled = ImmutableSet.copyOf(
+          cfg.getStringList("index", null, "testDisable"));
+    }
+
+    @Override
+    public void start() {
+      for (IndexDefinition<?, ?, ?> def : defs) {
+        start(def);
+      }
+    }
+
+    private <K, V, I extends Index<K, V>> void start(
+        IndexDefinition<K, V, I> def) {
+      if (disabled.contains(def.getName())) {
+        return;
+      }
+      Schema<V> schema;
+      Integer v = singleVersions.get(def.getName());
+      if (v == null) {
+        schema = def.getLatest();
+      } else {
+        schema = def.getSchemas().get(v);
+        if (schema == null) {
+          throw new ProvisionException(String.format(
+                "Unrecognized %s schema version: %s", def.getName(), v));
+        }
+      }
+      I index = def.getIndexFactory().create(schema);
+      def.getIndexCollection().setSearchIndex(index);
+      def.getIndexCollection().addWriteIndex(index);
+    }
+
+    @Override
+    public void stop() {
+      // Do nothing; indexes are closed by IndexCollection.
+    }
+  }
+}
\ No newline at end of file
diff --git a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
index bef57d0..a272864 100644
--- a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -55,6 +55,8 @@
   private static final String pkg = "com.google.gerrit.pgm";
   public static final String NOT_ARCHIVED = "NOT_ARCHIVED";
 
+  private static ClassLoader daemonClassLoader;
+
   public static void main(final String[] argv) throws Exception {
     System.exit(mainImpl(argv));
   }
@@ -102,6 +104,44 @@
     return invokeProgram(cl, argv);
   }
 
+  public static void daemonStart(final String[] argv) throws Exception {
+    if (daemonClassLoader != null) {
+      throw new IllegalStateException(
+        "daemonStart can be called only once per JVM instance");
+    }
+    final ClassLoader cl = libClassLoader(false);
+    Thread.currentThread().setContextClassLoader(cl);
+
+    daemonClassLoader = cl;
+
+    String[] daemonArgv = new String[argv.length + 1];
+    daemonArgv[0] = "daemon";
+    for (int i = 0; i < argv.length; i++) {
+      daemonArgv[i + 1] = argv[i];
+    }
+    int res = invokeProgram(cl, daemonArgv);
+    if (res != 0) {
+      throw new Exception("Unexpected return value: " + res);
+    }
+  }
+
+  public static void daemonStop(final String[] argv) throws Exception {
+    if (daemonClassLoader == null) {
+      throw new IllegalStateException(
+        "daemonStop can be called only after call to daemonStop");
+    }
+    String[] daemonArgv = new String[argv.length + 2];
+    daemonArgv[0] = "daemon";
+    daemonArgv[1] = "--stop-only";
+    for (int i = 0; i < argv.length; i++) {
+      daemonArgv[i + 2] = argv[i];
+    }
+    int res = invokeProgram(daemonClassLoader, daemonArgv);
+    if (res != 0) {
+      throw new Exception("Unexpected return value: " + res);
+    }
+  }
+
   private static boolean isProlog(String cn) {
     return "PrologShell".equals(cn) || "Rulec".equals(cn);
   }
diff --git a/gerrit-lucene/BUCK b/gerrit-lucene/BUCK
index 771a021..f4f097c 100644
--- a/gerrit-lucene/BUCK
+++ b/gerrit-lucene/BUCK
@@ -27,6 +27,7 @@
     '//gerrit-extension-api:api',
     '//gerrit-reviewdb:server',
     '//gerrit-server:server',
+    '//gerrit-index:index',
     '//lib:guava',
     '//lib:gwtorm',
     '//lib/guice:guice',
diff --git a/gerrit-lucene/BUILD b/gerrit-lucene/BUILD
index 2f1cba7..de010eb 100644
--- a/gerrit-lucene/BUILD
+++ b/gerrit-lucene/BUILD
@@ -25,6 +25,7 @@
     '//gerrit-common:annotations',
     '//gerrit-common:server',
     '//gerrit-extension-api:api',
+    '//gerrit-index:index',
     '//gerrit-reviewdb:server',
     '//gerrit-server:server',
     '//lib:guava',
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index eb0dfaa..e869afb 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -25,6 +25,7 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.index.IndexUtils;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
@@ -51,7 +52,6 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.store.AlreadyClosedException;
 import org.apache.lucene.store.Directory;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -75,17 +75,6 @@
     return f.getName() + "_SORT";
   }
 
-  public static void setReady(SitePaths sitePaths, String name, int version,
-      boolean ready) throws IOException {
-    try {
-      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
-      cfg.setReady(name, version, ready);
-      cfg.save();
-    } catch (ConfigInvalidException e) {
-      throw new IOException(e);
-    }
-  }
-
   private final Schema<V> schema;
   private final SitePaths sitePaths;
   private final Directory dir;
@@ -198,7 +187,7 @@
 
   @Override
   public void markReady(boolean ready) throws IOException {
-    setReady(sitePaths, name, schema.getVersion(), ready);
+    IndexUtils.setReady(sitePaths, name, schema.getVersion(), ready);
   }
 
   @Override
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index b5b391e..80adbb9 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -18,17 +18,15 @@
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.lucene.LuceneVersionManager.CHANGES_PREFIX;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 
-import com.google.common.base.Function;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Collections2;
 import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
@@ -36,6 +34,7 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.index.IndexUtils;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -54,6 +53,7 @@
 import com.google.gerrit.server.index.change.ChangeField.PatchSetProtoField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -129,8 +129,10 @@
   private static final String HASHTAG_FIELD =
       ChangeField.HASHTAG_CASE_AWARE.getName();
   private static final String STAR_FIELD = ChangeField.STAR.getName();
-  @Deprecated
-  private static final String STARREDBY_FIELD = ChangeField.STARREDBY.getName();
+  private static final String SUBMIT_RECORD_LENIENT_FIELD =
+      ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName();
+  private static final String SUBMIT_RECORD_STRICT_FIELD =
+      ChangeField.STORED_SUBMIT_RECORD_STRICT.getName();
 
   static Term idTerm(ChangeData cd) {
     return QueryBuilder.intTerm(LEGACY_ID.getName(), cd.getId().get());
@@ -320,7 +322,7 @@
         throw new OrmException("interrupted");
       }
 
-      final Set<String> fields = fields(opts);
+      final Set<String> fields = IndexUtils.fields(opts);
       return new ChangeDataResults(
           executor.submit(new Callable<List<Document>>() {
             @Override
@@ -397,7 +399,7 @@
         close();
         throw new OrmRuntimeException(e);
       } catch (ExecutionException e) {
-        Throwables.propagateIfPossible(e.getCause());
+        Throwables.throwIfUnchecked(e.getCause());
         throw new OrmRuntimeException(e.getCause());
       }
     }
@@ -408,31 +410,6 @@
     }
   }
 
-  private Set<String> fields(QueryOptions opts) {
-    // Ensure we request enough fields to construct a ChangeData.
-    Set<String> fs = opts.fields();
-    if (fs.contains(CHANGE.getName())) {
-      // A Change is always sufficient.
-      return fs;
-    }
-
-    if (!schema.hasField(PROJECT)) {
-      // Schema is not new enough to have project field. Ensure we have ID
-      // field, and call createOnlyWhenNoteDbDisabled from toChangeData below.
-      if (fs.contains(LEGACY_ID.getName())) {
-        return fs;
-      }
-      return Sets.union(fs, ImmutableSet.of(LEGACY_ID.getName()));
-    }
-
-    // New enough schema to have project field, so ensure that is requested.
-    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID.getName())) {
-      return fs;
-    }
-    return Sets.union(fs,
-        ImmutableSet.of(LEGACY_ID.getName(), PROJECT.getName()));
-  }
-
   private static Multimap<String, IndexableField> fields(Document doc,
       Set<String> fields) {
     Multimap<String, IndexableField> stored =
@@ -488,15 +465,16 @@
     if (fields.contains(HASHTAG_FIELD)) {
       decodeHashtags(doc, cd);
     }
-    if (fields.contains(STARREDBY_FIELD)) {
-      decodeStarredBy(doc, cd);
-    }
     if (fields.contains(STAR_FIELD)) {
       decodeStar(doc, cd);
     }
     if (fields.contains(REVIEWER_FIELD)) {
       decodeReviewers(doc, cd);
     }
+    decodeSubmitRecords(doc, SUBMIT_RECORD_STRICT_FIELD,
+        ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
+    decodeSubmitRecords(doc, SUBMIT_RECORD_LENIENT_FIELD,
+        ChangeField.SUBMIT_RULE_OPTIONS_LENIENT, cd);
     return cd;
   }
 
@@ -568,17 +546,6 @@
     cd.setHashtags(hashtags);
   }
 
-  @Deprecated
-  private void decodeStarredBy(Multimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> starredBy = doc.get(STARREDBY_FIELD);
-    Set<Account.Id> accounts =
-        Sets.newHashSetWithExpectedSize(starredBy.size());
-    for (IndexableField r : starredBy) {
-      accounts.add(new Account.Id(r.numericValue().intValue()));
-    }
-    cd.setStarredBy(accounts);
-  }
-
   private void decodeStar(Multimap<String, IndexableField> doc, ChangeData cd) {
     Collection<IndexableField> star = doc.get(STAR_FIELD);
     Multimap<Account.Id, String> stars = ArrayListMultimap.create();
@@ -592,17 +559,20 @@
     cd.setStars(stars);
   }
 
-  private void decodeReviewers(Multimap<String, IndexableField> doc, ChangeData cd) {
+  private void decodeReviewers(Multimap<String, IndexableField> doc,
+      ChangeData cd) {
     cd.setReviewers(
         ChangeField.parseReviewerFieldValues(
             FluentIterable.from(doc.get(REVIEWER_FIELD))
-                .transform(
-                    new Function<IndexableField, String>() {
-                      @Override
-                      public String apply(IndexableField in) {
-                        return in.stringValue();
-                      }
-                    })));
+                .transform(IndexableField::stringValue)));
+  }
+
+  private void decodeSubmitRecords(Multimap<String, IndexableField> doc,
+      String field, SubmitRuleOptions opts, ChangeData cd) {
+    ChangeField.parseSubmitRecords(
+        Collections2.transform(
+            doc.get(field), f -> f.binaryValue().utf8ToString()),
+        opts, cd);
   }
 
   private static <T> List<T> decodeProtos(Multimap<String, IndexableField> doc,
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
index f5d5146..58890176 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -15,37 +15,23 @@
 package com.google.gerrit.lucene;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.index.SingleVersionModule;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.IndexDefinition;
 import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.inject.Inject;
 import com.google.inject.Provides;
-import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
-import com.google.inject.name.Named;
-import com.google.inject.name.Names;
 
 import org.apache.lucene.search.BooleanQuery;
 import org.eclipse.jgit.lib.Config;
 
-import java.util.Collection;
 import java.util.Map;
-import java.util.Set;
 
 public class LuceneIndexModule extends LifecycleModule {
-  private static final String SINGLE_VERSIONS =
-      "LuceneIndexModule/SingleVersions";
-
   public static LuceneIndexModule singleVersionAllLatest(int threads) {
     return new LuceneIndexModule(ImmutableMap.<String, Integer> of(), threads);
   }
@@ -86,7 +72,7 @@
     if (singleVersions == null) {
       install(new MultiVersionModule());
     } else {
-      install(new SingleVersionModule());
+      install(new SingleVersionModule(singleVersions));
     }
   }
 
@@ -104,66 +90,4 @@
       listener().to(LuceneVersionManager.class);
     }
   }
-
-  private class SingleVersionModule extends LifecycleModule {
-    @Override
-    public void configure() {
-      listener().to(SingleVersionListener.class);
-      bind(new TypeLiteral<Map<String, Integer>>() {})
-          .annotatedWith(Names.named(SINGLE_VERSIONS))
-          .toInstance(singleVersions);
-    }
-  }
-
-  @Singleton
-  static class SingleVersionListener implements LifecycleListener {
-    private final Set<String> disabled;
-    private final Collection<IndexDefinition<?, ?, ?>> defs;
-    private final Map<String, Integer> singleVersions;
-
-    @Inject
-    SingleVersionListener(
-        @GerritServerConfig Config cfg,
-        Collection<IndexDefinition<?, ?, ?>> defs,
-        @Named(SINGLE_VERSIONS) Map<String, Integer> singleVersions) {
-      this.defs = defs;
-      this.singleVersions = singleVersions;
-
-      disabled = ImmutableSet.copyOf(
-          cfg.getStringList("index", null, "testDisable"));
-    }
-
-    @Override
-    public void start() {
-      for (IndexDefinition<?, ?, ?> def : defs) {
-        start(def);
-      }
-    }
-
-    private <K, V, I extends Index<K, V>> void start(
-        IndexDefinition<K, V, I> def) {
-      if (disabled.contains(def.getName())) {
-        return;
-      }
-      Schema<V> schema;
-      Integer v = singleVersions.get(def.getName());
-      if (v == null) {
-        schema = def.getLatest();
-      } else {
-        schema = def.getSchemas().get(v);
-        if (schema == null) {
-          throw new ProvisionException(String.format(
-                "Unrecognized %s schema version: %s", def.getName(), v));
-        }
-      }
-      I index = def.getIndexFactory().create(schema);
-      def.getIndexCollection().setSearchIndex(index);
-      def.getIndexCollection().addWriteIndex(index);
-    }
-
-    @Override
-    public void stop() {
-      // Do nothing; indexes are closed by IndexCollection.
-    }
-  }
 }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
index b46f1f6..2f871fc 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.Maps;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.index.GerritIndexStatus;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.Index;
diff --git a/gerrit-main/BUILD b/gerrit-main/BUILD
new file mode 100644
index 0000000..67863c5
--- /dev/null
+++ b/gerrit-main/BUILD
@@ -0,0 +1,13 @@
+java_binary(
+  name = 'main_bin',
+  main_class = 'Main',
+  runtime_deps = [':main_lib'],
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'main_lib',
+  srcs = ['src/main/java/Main.java'],
+  deps = ['//gerrit-launcher:launcher'],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-main/src/main/java/Main.java b/gerrit-main/src/main/java/Main.java
index a29f1c6..58de6a4 100644
--- a/gerrit-main/src/main/java/Main.java
+++ b/gerrit-main/src/main/java/Main.java
@@ -31,11 +31,11 @@
 
   private static boolean onSupportedJavaVersion() {
     final String version = System.getProperty("java.specification.version");
-    if (1.7 <= parse(version)) {
+    if (1.8 <= parse(version)) {
       return true;
 
     }
-    System.err.println("fatal: Gerrit Code Review requires Java 7 or later");
+    System.err.println("fatal: Gerrit Code Review requires Java 8 or later");
     System.err.println("       (trying to run on Java " + version + ")");
     return false;
   }
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
index 3a40252..791f9fd 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
@@ -24,12 +24,12 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.auth.openid.OpenIdUrls;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.httpd.LoginUrlToken;
 import com.google.gerrit.httpd.template.SiteHeaderFooter;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
diff --git a/gerrit-patch-jgit/BUCK b/gerrit-patch-jgit/BUCK
index 09ccf9c..4a4929e 100644
--- a/gerrit-patch-jgit/BUCK
+++ b/gerrit-patch-jgit/BUCK
@@ -33,7 +33,7 @@
     'org/eclipse/jgit/diff/Edit.java;' +
     'cd $TMP;' +
     'zip -Dq $OUT org/eclipse/jgit/diff/Edit.java',
-  out = 'edit.src.zip',
+  out = 'edit-sources.jar',
 )
 
 java_library(
@@ -61,6 +61,5 @@
     '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib:junit',
   ],
-  source_under_test = [':server'],
   visibility = ['//tools/eclipse:classpath'],
 )
diff --git a/gerrit-patch-jgit/BUILD b/gerrit-patch-jgit/BUILD
index 13a2fe0..98cd32a 100644
--- a/gerrit-patch-jgit/BUILD
+++ b/gerrit-patch-jgit/BUILD
@@ -33,7 +33,7 @@
     'zip -Dq $$ROOT/$@ org/eclipse/jgit/diff/Edit.java',
   ]),
   tools = ['@jgit_src//file'],
-  out = 'edit.srcjar',
+  outs = [ 'edit.srcjar' ],
 )
 
 java_library(
diff --git a/gerrit-pgm/BUCK b/gerrit-pgm/BUCK
index 4be941c..d5abf99 100644
--- a/gerrit-pgm/BUCK
+++ b/gerrit-pgm/BUCK
@@ -47,7 +47,7 @@
     ':init-api',
     ':util',
     '//gerrit-common:annotations',
-    '//gerrit-lucene:lucene',
+    '//gerrit-index:index',
     '//lib:args4j',
     '//lib:derby',
     '//lib:gwtjsonrpc',
@@ -66,6 +66,7 @@
 
 REST_UTIL_DEPS = [
   '//gerrit-cache-h2:cache-h2',
+  '//gerrit-elasticsearch:elasticsearch',
   '//gerrit-util-cli:cli',
   '//lib:args4j',
   '//lib:gwtorm',
@@ -120,6 +121,7 @@
   ':init-api',
   ':util',
   '//gerrit-cache-h2:cache-h2',
+  '//gerrit-elasticsearch:elasticsearch',
   '//gerrit-gpg:gpg',
   '//gerrit-lucene:lucene',
   '//gerrit-oauth:oauth',
@@ -128,7 +130,6 @@
   '//lib:gwtorm',
   '//lib:protobuf',
   '//lib:servlet-api-3_1',
-  '//lib/auto:auto-value',
   '//lib/prolog:cafeteria',
   '//lib/prolog:compiler',
   '//lib/prolog:runtime',
@@ -180,5 +181,4 @@
     '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib/jgit/org.eclipse.jgit.junit:junit',
   ],
-  source_under_test = [':pgm'],
 )
diff --git a/gerrit-pgm/BUILD b/gerrit-pgm/BUILD
index 59b371a..4f2b609 100644
--- a/gerrit-pgm/BUILD
+++ b/gerrit-pgm/BUILD
@@ -1,5 +1,6 @@
 load('//tools/bzl:java.bzl', 'java_library2')
 load('//tools/bzl:junit.bzl', 'junit_tests')
+load('//tools/bzl:license.bzl', 'license_test')
 
 SRCS = 'src/main/java/com/google/gerrit/pgm/'
 RSRCS = 'src/main/resources/com/google/gerrit/pgm/'
@@ -19,6 +20,7 @@
   '//lib/guice:guice-assistedinject',
   '//lib/guice:guice-servlet',
   '//lib/jgit/org.eclipse.jgit:jgit',
+  '//lib/joda:joda-time',
   '//lib/log:api',
   '//lib/log:log4j',
 ]
@@ -43,6 +45,7 @@
     ':init-api',
     ':util',
     '//gerrit-common:annotations',
+    '//gerrit-index:index',
     '//gerrit-launcher:launcher', # We want this dep to be provided_deps
     '//gerrit-lucene:lucene',
     '//lib:args4j',
@@ -108,6 +111,7 @@
   ':init-api',
   ':util',
   '//gerrit-cache-h2:cache-h2',
+  '//gerrit-elasticsearch:elasticsearch',
   '//gerrit-gpg:gpg',
   '//gerrit-lucene:lucene',
   '//gerrit-oauth:oauth',
@@ -116,7 +120,6 @@
   '//lib:gwtorm',
   '//lib:protobuf',
   '//lib:servlet-api-3_1-without-neverlink',
-  '//lib/auto:auto-value',
   '//lib/prolog:cafeteria',
   '//lib/prolog:compiler',
   '//lib/prolog:runtime',
@@ -138,6 +141,7 @@
   resources = glob([RSRCS + '*']),
   deps = DEPS + REST_PGM_DEPS + [ # We want all these deps to be provided_deps
     '//gerrit-launcher:launcher',
+    '//lib/auto:auto-value',
   ],
   visibility = ['//visibility:public'],
 )
@@ -159,3 +163,8 @@
     '//lib/jgit/org.eclipse.jgit.junit:junit',
   ],
 )
+
+license_test(
+  name = "pgm_license_test",
+  target = ":pgm",
+)
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index 3ff6451..7d62dd7 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -20,9 +20,10 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.EventBroker;
+import com.google.gerrit.elasticsearch.ElasticIndexModule;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
-import com.google.gerrit.httpd.GerritOptions;
 import com.google.gerrit.httpd.GetUserFilter;
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
@@ -46,7 +47,6 @@
 import com.google.gerrit.pgm.util.LogFileCompressor;
 import com.google.gerrit.pgm.util.RuntimeShutdown;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
@@ -56,6 +56,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.RestCacheAdminModule;
 import com.google.gerrit.server.events.StreamEventsApiListener;
@@ -67,7 +68,7 @@
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
-import com.google.gerrit.server.mail.SmtpEmailSender;
+import com.google.gerrit.server.mail.send.SmtpEmailSender;
 import com.google.gerrit.server.mime.MimeUtil2Module;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
@@ -125,7 +126,7 @@
   private boolean sshd = true;
 
   @Option(name = "--disable-sshd", usage = "Disable the internal SSH daemon")
-  void setDisableSshd(@SuppressWarnings("unused")  boolean arg) {
+  void setDisableSshd(@SuppressWarnings("unused") boolean arg) {
     sshd = false;
   }
 
@@ -151,6 +152,9 @@
       usage = "Init site before starting the daemon")
   private boolean doInit;
 
+  @Option(name = "--stop-only", usage = "Stop the daemon", hidden = true)
+  private boolean stopOnly;
+
   private final LifecycleManager manager = new LifecycleManager();
   private Injector dbInjector;
   private Injector cfgInjector;
@@ -171,7 +175,8 @@
   }
 
   @VisibleForTesting
-  public Daemon(Runnable serverStarted) {
+  public Daemon(Runnable serverStarted, Path sitePath) {
+    super (sitePath);
     this.serverStarted = serverStarted;
   }
 
@@ -181,6 +186,10 @@
 
   @Override
   public int run() throws Exception {
+    if (stopOnly) {
+      RuntimeShutdown.manualShutdown();
+      return 0;
+    }
     if (doInit) {
       try {
         new Init(getSitePath()).run();
@@ -214,14 +223,7 @@
         @Override
         public void run() {
           log.info("caught shutdown, cleaning up");
-          if (runId != null) {
-            try {
-              Files.delete(runFile);
-            } catch (IOException err) {
-              log.warn("failed to delete " + runFile, err);
-            }
-          }
-          manager.stop();
+          stop();
         }
       });
 
@@ -313,6 +315,13 @@
 
   @VisibleForTesting
   public void stop() {
+    if (runId != null) {
+      try {
+        Files.delete(runFile);
+      } catch (IOException err) {
+        log.warn("failed to delete " + runFile, err);
+      }
+    }
     manager.stop();
   }
 
@@ -401,15 +410,18 @@
     return cfgInjector.createChildInjector(modules);
   }
 
-  private AbstractModule createIndexModule() {
+  private Module createIndexModule() {
     if (slave) {
       return new DummyIndexModule();
     }
+    if (luceneModule != null) {
+      return luceneModule;
+    }
     switch (indexType) {
       case LUCENE:
-        return luceneModule != null
-            ? luceneModule
-            : LuceneIndexModule.latestVersionWithOnlineUpgrade();
+        return LuceneIndexModule.latestVersionWithOnlineUpgrade();
+      case ELASTICSEARCH:
+        return ElasticIndexModule.latestVersionWithOnlineUpgrade();
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
     }
@@ -419,6 +431,7 @@
     indexType = IndexModule.getIndexType(cfgInjector);
     switch (indexType) {
       case LUCENE:
+      case ELASTICSEARCH:
         break;
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
index 05a0d70..b3813f6 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
@@ -14,9 +14,8 @@
 
 package com.google.gerrit.pgm;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
-import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
 import com.google.gerrit.common.IoUtil;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.PluginData;
@@ -42,6 +41,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 
 /** Initialize a new Gerrit installation. */
 public class Init extends BaseInit {
@@ -247,16 +247,10 @@
     if (nullOrEmpty(installPlugins) || nullOrEmpty(plugins)) {
       return;
     }
-    ArrayList<String> copy = Lists.newArrayList(installPlugins);
-    List<String> pluginNames = Lists.transform(plugins, new Function<PluginData, String>() {
-      @Override
-      public String apply(PluginData input) {
-        return input.name;
-      }
-    });
-    copy.removeAll(pluginNames);
-    if (!copy.isEmpty()) {
-      ui.message("Cannot find plugin(s): %s\n", Joiner.on(", ").join(copy));
+    Set<String> missing = Sets.newHashSet(installPlugins);
+    plugins.stream().forEach(p -> missing.remove(p.name));
+    if (!missing.isEmpty()) {
+      ui.message("Cannot find plugin(s): %s\n", Joiner.on(", ").join(missing));
       listPlugins = true;
     }
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
index 0adb1af..755ab1b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
-import com.google.common.base.Function;
 import com.google.common.base.Predicates;
 import com.google.common.base.Stopwatch;
 import com.google.common.collect.ArrayListMultimap;
@@ -29,6 +29,7 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.common.FormatUtil;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -43,12 +44,16 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.ChainedReceiveCommands;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.DummyIndexModule;
 import com.google.gerrit.server.index.change.ReindexAfterUpdate;
-import com.google.gerrit.server.notedb.ChangeRebuilder;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -58,9 +63,12 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.kohsuke.args4j.Option;
@@ -68,6 +76,7 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -108,6 +117,9 @@
   private GitRepositoryManager repoManager;
 
   @Inject
+  private NoteDbUpdateManager.Factory updateManagerFactory;
+
+  @Inject
   private NotesMigration notesMigration;
 
   @Inject
@@ -116,6 +128,9 @@
   @Inject
   private WorkQueue workQueue;
 
+  @Inject
+  private ChangeBundleReader bundleReader;
+
   @Override
   public int run() throws Exception {
     mustHaveValidSite();
@@ -154,7 +169,7 @@
               @Override
               public Boolean call() {
                 try (ReviewDb db = unwrapDb(schemaFactory.open())) {
-                  return rebuilder.rebuildProject(
+                  return rebuildProject(
                       db, changesByProject, project, allUsersRepo);
                 } catch (Exception e) {
                   log.error("Error rebuilding project " + project, e);
@@ -234,13 +249,8 @@
         ArrayListMultimap.create();
     try (ReviewDb db = schemaFactory.open()) {
       if (projects.isEmpty() && !changes.isEmpty()) {
-        Iterable<Change> todo = unwrapDb(db).changes().get(
-            Iterables.transform(changes, new Function<Integer, Change.Id>() {
-              @Override
-              public Change.Id apply(Integer in) {
-                return new Change.Id(in);
-              }
-            }));
+        Iterable<Change> todo = unwrapDb(db).changes()
+            .get(Iterables.transform(changes, Change.Id::new));
         for (Change c : todo) {
           changesByProject.put(c.getProject(), c.getId());
         }
@@ -263,4 +273,37 @@
       return ImmutableMultimap.copyOf(changesByProject);
     }
   }
+
+  private boolean rebuildProject(ReviewDb db,
+      ImmutableMultimap<Project.NameKey, Change.Id> allChanges,
+      Project.NameKey project, Repository allUsersRepo)
+      throws IOException, OrmException {
+    checkArgument(allChanges.containsKey(project));
+    boolean ok = true;
+    ProgressMonitor pm = new TextProgressMonitor(new PrintWriter(System.out));
+    pm.beginTask(
+        FormatUtil.elide(project.get(), 50), allChanges.get(project).size());
+    try (NoteDbUpdateManager manager = updateManagerFactory.create(project);
+        ObjectInserter allUsersInserter = allUsersRepo.newObjectInserter();
+        RevWalk allUsersRw = new RevWalk(allUsersInserter.newReader())) {
+      manager.setAllUsersRepo(allUsersRepo, allUsersRw, allUsersInserter,
+          new ChainedReceiveCommands(allUsersRepo));
+      for (Change.Id changeId : allChanges.get(project)) {
+        try {
+          rebuilder.buildUpdates(
+              manager, bundleReader.fromReviewDb(db, changeId));
+        } catch (NoPatchSetsException e) {
+          log.warn(e.getMessage());
+        } catch (Throwable t) {
+          log.error("Failed to rebuild change " + changeId, t);
+          ok = false;
+        }
+        pm.update(1);
+      }
+      manager.execute();
+    } finally {
+      pm.endTask();
+    }
+    return ok;
+  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
index 2e7d88a..ee0d02f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+import static java.util.stream.Collectors.toSet;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Die;
+import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lucene.LuceneIndexModule;
@@ -134,14 +133,8 @@
     }
 
     checkNotNull(indexDefs, "Called this method before injectMembers?");
-    Set<String> valid = FluentIterable.from(indexDefs).transform(
-        new Function<IndexDefinition<?, ?, ?>, String>() {
-          @Override
-          public String apply(IndexDefinition<?, ?, ?> input) {
-            return input.getName();
-          }
-        }).toSortedSet(Ordering.natural());
-
+    Set<String> valid = indexDefs.stream()
+        .map(IndexDefinition::getName).sorted().collect(toSet());
     Set<String> invalid = Sets.difference(Sets.newHashSet(indices), valid);
     if (invalid.isEmpty()) {
       return;
@@ -169,6 +162,10 @@
         indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(
             versions, threads);
         break;
+      case ELASTICSEARCH:
+        indexModule = ElasticIndexModule
+            .singleVersionWithExplicitVersions(versions, threads);
+        break;
       default:
         throw new IllegalStateException("unsupported index.type");
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 9d27170..f5212ab 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -18,9 +18,9 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.pgm.http.jetty.HttpLog.HttpLogFactory;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.ThreadSettingsConfig;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 2de71cc..136ec5a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -27,7 +28,6 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java
index 6b30f80..f4bcd86 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java
@@ -16,11 +16,11 @@
 
 import static com.google.gerrit.pgm.init.api.InitUtil.dnOf;
 
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -135,8 +135,4 @@
       libraries.bouncyCastlePGP.downloadRequired();
     }
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
index 33dc204..aac2b36 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
@@ -89,8 +89,4 @@
       }
     }
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
index 36754a1..03ddd7b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
@@ -117,8 +117,4 @@
   private static String javaHome() {
     return System.getProperty("java.home");
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
index 7e4d3c1..47783e4 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
@@ -102,8 +102,4 @@
           GerritServerIdProvider.KEY, GerritServerIdProvider.generate());
     }
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDev.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDev.java
new file mode 100644
index 0000000..5500da8
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDev.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2016 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.pgm.init;
+
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class InitDev implements InitStep {
+  private final InitFlags flags;
+  private final Section plugins;
+
+  @Inject
+  InitDev(InitFlags flags,
+      Section.Factory sections) {
+    this.flags = flags;
+    this.plugins = sections.get("plugins", null);
+  }
+
+  @Override
+  public void run() throws Exception {
+    if (!flags.dev) {
+      return;
+    }
+    plugins.set("allowRemoteAdmin", "true");
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java
index d8fd509..19eaa3c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java
@@ -47,8 +47,4 @@
     }
     FileUtil.mkdirsOrDie(d, "Cannot create");
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitHttpd.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitHttpd.java
index a907d46..72a70c9 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitHttpd.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitHttpd.java
@@ -202,8 +202,4 @@
       throw die("Cannot delete " + tmpdir, e);
     }
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
index 018211b..c8d8edb 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -15,7 +15,8 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.common.collect.Iterables;
-import com.google.gerrit.lucene.AbstractLuceneIndex;
+import com.google.common.collect.Sets;
+import com.google.gerrit.index.IndexUtils;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -61,9 +62,17 @@
       type = index.select("Type", "type", type);
     }
 
+    if (type == IndexType.ELASTICSEARCH) {
+      index.select("Transport protocol", "protocol", "http",
+          Sets.newHashSet("http", "https"));
+      index.string("Hostname", "hostname", "localhost");
+      index.string("Port", "port", "9200");
+      index.string("Index Name", "name", "gerrit");
+    }
+
     if ((site.isNew || isEmptySite()) && type == IndexType.LUCENE) {
       for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) {
-        AbstractLuceneIndex.setReady(
+        IndexUtils.setReady(
             site, def.getName(), def.getLatest().getVersion(), true);
       }
     } else {
@@ -87,8 +96,4 @@
       return true;
     }
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
index b5aa625..a442f29 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
@@ -64,6 +64,7 @@
     step().to(InitHttpd.class);
     step().to(InitCache.class);
     step().to(InitPlugins.class);
+    step().to(InitDev.class);
   }
 
   protected LinkedBindingBuilder<InitStep> step() {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java
index 5c7eefd..afac097 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.mail.SmtpEmailSender.Encryption;
+import com.google.gerrit.server.mail.send.SmtpEmailSender.Encryption;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -64,8 +64,4 @@
     sendemail.string("SMTP username", "smtpUser", username);
     sendemail.password("smtpUser", "smtpPass");
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
index cb4439a..904d4f2 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.HostPlatform;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,6 +32,7 @@
 import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
 
 import java.io.IOException;
+import java.lang.ProcessBuilder.Redirect;
 import java.net.InetSocketAddress;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -103,25 +105,30 @@
         //
         final String comment = "gerrit-code-review@" + hostname();
 
+        // Workaround for JDK-6518827 - zero-length argument ignored on Win32
+        String emptyPassphraseArg = HostPlatform.isWin32() ? "\"\"" : "";
+
         System.err.print(" rsa...");
         System.err.flush();
-        Runtime.getRuntime().exec(new String[] {"ssh-keygen",
+        new ProcessBuilder("ssh-keygen",
             "-q" /* quiet */,
             "-t", "rsa",
-            "-P", "",
+            "-P", emptyPassphraseArg,
             "-C", comment,
-            "-f", site.ssh_rsa.toAbsolutePath().toString(),
-            }).waitFor();
+            "-f", site.ssh_rsa.toAbsolutePath().toString()
+        ).redirectError(Redirect.INHERIT).redirectOutput(Redirect.INHERIT)
+            .start().waitFor();
 
         System.err.print(" dsa...");
         System.err.flush();
-        Runtime.getRuntime().exec(new String[] {"ssh-keygen",
+        new ProcessBuilder("ssh-keygen",
             "-q" /* quiet */,
             "-t", "dsa",
-            "-P", "",
+            "-P", emptyPassphraseArg,
             "-C", comment,
-            "-f", site.ssh_dsa.toAbsolutePath().toString(),
-            }).waitFor();
+            "-f", site.ssh_dsa.toAbsolutePath().toString()
+        ).redirectError(Redirect.INHERIT).redirectOutput(Redirect.INHERIT)
+            .start().waitFor();
 
       } else {
         // Generate the SSH daemon host key ourselves. This is complex
@@ -163,8 +170,4 @@
       System.err.println(" done");
     }
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index f16e2ec..7d6dc32 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -29,7 +29,7 @@
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.pgm.init.api.Section.Factory;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.mail.OutgoingEmail;
+import com.google.gerrit.server.mail.EmailModule;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -99,21 +99,34 @@
     chmod(0755, site.gerrit_sh);
     chmod(0700, site.tmp_dir);
 
-    extractMailExample("Abandoned.vm");
-    extractMailExample("AddKey.vm");
-    extractMailExample("ChangeFooter.vm");
-    extractMailExample("ChangeSubject.vm");
-    extractMailExample("Comment.vm");
-    extractMailExample("CommentFooter.vm");
-    extractMailExample("DeleteReviewer.vm");
-    extractMailExample("DeleteVote.vm");
-    extractMailExample("Footer.vm");
-    extractMailExample("Merged.vm");
-    extractMailExample("NewChange.vm");
-    extractMailExample("RegisterNewEmail.vm");
-    extractMailExample("ReplacePatchSet.vm");
-    extractMailExample("Restored.vm");
-    extractMailExample("Reverted.vm");
+    extractMailExample("Abandoned.soy");
+    extractMailExample("AbandonedHtml.soy");
+    extractMailExample("AddKey.soy");
+    extractMailExample("ChangeFooter.soy");
+    extractMailExample("ChangeFooterHtml.soy");
+    extractMailExample("ChangeSubject.soy");
+    extractMailExample("Comment.soy");
+    extractMailExample("CommentHtml.soy");
+    extractMailExample("CommentFooter.soy");
+    extractMailExample("CommentFooterHtml.soy");
+    extractMailExample("DeleteReviewer.soy");
+    extractMailExample("DeleteReviewerHtml.soy");
+    extractMailExample("DeleteVote.soy");
+    extractMailExample("DeleteVoteHtml.soy");
+    extractMailExample("Footer.soy");
+    extractMailExample("FooterHtml.soy");
+    extractMailExample("HeaderHtml.soy");
+    extractMailExample("Merged.soy");
+    extractMailExample("MergedHtml.soy");
+    extractMailExample("NewChange.soy");
+    extractMailExample("NewChangeHtml.soy");
+    extractMailExample("RegisterNewEmail.soy");
+    extractMailExample("ReplacePatchSet.soy");
+    extractMailExample("ReplacePatchSetHtml.soy");
+    extractMailExample("Restored.soy");
+    extractMailExample("RestoredHtml.soy");
+    extractMailExample("Reverted.soy");
+    extractMailExample("RevertedHtml.soy");
 
     if (!ui.isBatch()) {
       System.err.println();
@@ -143,7 +156,7 @@
 
   private void extractMailExample(String orig) throws Exception {
     Path ex = site.mail_dir.resolve(orig + ".example");
-    extract(ex, OutgoingEmail.class, orig);
+    extract(ex, EmailModule.class, orig);
     chmod(0444, ex);
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
index 52f9096..87b24f9 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
@@ -286,8 +286,4 @@
     }
     return null;
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
index 6739ce0..e47f23a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.VersionedMetaDataOnInit;
@@ -34,6 +33,7 @@
 
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
 
 public class VersionedAuthorizedKeysOnInit extends VersionedMetaDataOnInit {
   public interface Factory {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
index a7ebd33..81ee0a2 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm.init.api;
 
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GroupList;
@@ -66,7 +67,9 @@
   }
 
   private GroupList readGroupList() throws IOException {
-    return GroupList.parse(readUTF8(GroupList.FILE_NAME),
+    return GroupList.parse(
+        new Project.NameKey(project),
+        readUTF8(GroupList.FILE_NAME),
         GroupList.createLoggerSink(GroupList.FILE_NAME, log));
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java
index fd28399..9d4becc 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java
@@ -19,5 +19,5 @@
   void run() throws Exception;
 
   /** Executed after the site has been initialized */
-  void postRun() throws Exception;
+  default void postRun() throws Exception {}
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
index b953a0b..43fd991 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
@@ -37,9 +37,9 @@
 
 public abstract class VersionedMetaDataOnInit extends VersionedMetaData {
 
+  protected final String project;
   private final InitFlags flags;
   private final SitePaths site;
-  private final String project;
   private final String ref;
 
   protected VersionedMetaDataOnInit(InitFlags flags, SitePaths site,
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java
index 0360cd6..d39c2fd 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.CommitValidators;
 
 /** Module for batch programs that need git access. */
 public class BatchGitModule extends FactoryModule {
@@ -27,7 +26,6 @@
   protected void configure() {
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
-    factory(CommitValidators.Factory.class);
     install(new GitModule());
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index f076e54..689b606 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -58,7 +58,7 @@
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.group.GroupModule;
-import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
index dc3a915..86fef21 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
@@ -39,6 +39,10 @@
     cb.waitForShutdown();
   }
 
+  public static void manualShutdown() {
+    cb.manualShutdown();
+  }
+
   private RuntimeShutdown() {
   }
 
@@ -96,6 +100,11 @@
       }
     }
 
+    void manualShutdown() {
+      Runtime.getRuntime().removeShutdownHook(this);
+      run();
+    }
+
     void waitForShutdown() {
       synchronized (this) {
         while (!shutdownComplete) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
index 9e2da5c..a2e0450 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -78,6 +78,10 @@
   protected SiteProgram() {
   }
 
+  protected SiteProgram(Path sitePath) {
+    this.sitePath = sitePath;
+  }
+
   protected SiteProgram(Path sitePath, final Provider<DataSource> dsProvider) {
     this.sitePath = sitePath;
     this.dsProvider = dsProvider;
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
index 4d9d0f0..3bc6ae4 100644
--- a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
@@ -15,24 +15,24 @@
 
 # Version should match lib/bouncycastle/BUCK
 [library "bouncyCastleProvider"]
-  name = Bouncy Castle Crypto Provider v152
-  url = https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk15on/1.52/bcprov-jdk15on-1.52.jar
-  sha1 = 88a941faf9819d371e3174b5ed56a3f3f7d73269
+  name = Bouncy Castle Crypto Provider v155
+  url = https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk15on/1.55/bcprov-jdk15on-1.55.jar
+  sha1 = 935f2e57a00ec2c489cbd2ad830d4a399708f979
   remove = bcprov-.*[.]jar
 
 # Version should match lib/bouncycastle/BUCK
 [library "bouncyCastleSSL"]
-  name = Bouncy Castle Crypto SSL v152
-  url = https://repo1.maven.org/maven2/org/bouncycastle/bcpkix-jdk15on/1.52/bcpkix-jdk15on-1.52.jar
-  sha1 = b8ffac2bbc6626f86909589c8cc63637cc936504
+  name = Bouncy Castle Crypto SSL v155
+  url = https://repo1.maven.org/maven2/org/bouncycastle/bcpkix-jdk15on/1.55/bcpkix-jdk15on-1.55.jar
+  sha1 = 6392d8cba22b722c6570d660ca0b3921ff1bae4f
   needs = bouncyCastleProvider
   remove = bcpkix-.*[.]jar
 
 # Version should match lib/bouncycastle/BUCK
 [library "bouncyCastlePGP"]
-  name = Bouncy Castle Crypto OpenPGP v152
-  url = https://repo1.maven.org/maven2/org/bouncycastle/bcpg-jdk15on/1.52/bcpg-jdk15on-1.52.jar
-  sha1 = ff4665a4b5633ff6894209d5dd10b7e612291858
+  name = Bouncy Castle Crypto OpenPGP v155
+  url = https://repo1.maven.org/maven2/org/bouncycastle/bcpg-jdk15on/1.55/bcpg-jdk15on-1.55.jar
+  sha1 = 54ce841795ecdf10f24e50c48d4fdec59c691699
   needs = bouncyCastleProvider
   remove = bcpg-.*[.]jar
 
diff --git a/gerrit-plugin-api/BUCK b/gerrit-plugin-api/BUCK
index 8cbf1a1..4b2d677 100644
--- a/gerrit-plugin-api/BUCK
+++ b/gerrit-plugin-api/BUCK
@@ -13,10 +13,20 @@
 
 java_binary(
   name = 'plugin-api',
+  merge_manifests = False,
+  manifest_file = ':manifest',
   deps = [':lib'],
   visibility = ['PUBLIC'],
 )
 
+genrule(
+  name = 'manifest',
+  cmd = 'echo "Manifest-Version: 1.0" >$OUT;' +
+    'echo "Implementation-Title: Gerrit Plugin API" >>$OUT;' +
+    'echo "Implementation-Vendor: Gerrit Code Review Project" >>$OUT',
+  out = 'manifest.txt',
+)
+
 java_library(
   name = 'lib',
   exported_deps = PLUGIN_API + [
@@ -32,20 +42,31 @@
     '//lib:gson',
     '//lib:guava',
     '//lib:gwtorm',
+    '//lib:icu4j',
     '//lib:jsch',
+    '//lib:jsr305',
     '//lib:mime-util',
+    '//lib:protobuf',
     '//lib:servlet-api-3_1',
+    '//lib:soy',
     '//lib:velocity',
     '//lib/commons:lang',
     '//lib/dropwizard:dropwizard-core',
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
+    '//lib/guice:javax-inject',
+    '//lib/guice:multibindings',
     '//lib/guice:guice-servlet',
     '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet',
     '//lib/joda:joda-time',
     '//lib/log:api',
+    '//lib/log:log4j',
     '//lib/mina:sshd',
+    '//lib/ow2:ow2-asm',
+    '//lib/ow2:ow2-asm-analysis',
+    '//lib/ow2:ow2-asm-commons',
+    '//lib/ow2:ow2-asm-util',
     '//lib/prolog:compiler',
     '//lib/prolog:runtime',
   ],
@@ -64,7 +85,7 @@
   name = 'plugin-api-javadoc',
   title = 'Gerrit Review Plugin API Documentation',
   pkgs = ['com.google.gerrit'],
-  paths = [n for n in SRCS],
+  source_jar = ':plugin-api-src',
   srcs = glob([n + '**/*.java' for n in SRCS]),
   deps = [
     ':plugin-api',
@@ -73,5 +94,4 @@
     '//lib/bouncycastle:bcpkix',
   ],
   visibility = ['PUBLIC'],
-  do_it_wrong = True,
 )
diff --git a/gerrit-plugin-api/BUILD b/gerrit-plugin-api/BUILD
index 2c18ca6..e231a02 100644
--- a/gerrit-plugin-api/BUILD
+++ b/gerrit-plugin-api/BUILD
@@ -11,6 +11,45 @@
   '//gerrit-sshd:sshd',
 ]
 
+EXPORTS = [
+  '//gerrit-antlr:query_exception',
+  '//gerrit-antlr:query_parser',
+  '//gerrit-common:annotations',
+  '//gerrit-common:server',
+  '//gerrit-extension-api:api',
+  '//gerrit-gwtexpui:server',
+  '//gerrit-reviewdb:server',
+  '//lib/commons:lang',
+  '//lib/dropwizard:dropwizard-core',
+  '//lib/guice:guice',
+  '//lib/guice:guice-assistedinject',
+  '//lib/guice:guice-servlet',
+  '//lib/guice:javax-inject',
+  '//lib/guice:multibindings',
+  '//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet',
+  '//lib/jgit/org.eclipse.jgit:jgit',
+  '//lib/joda:joda-time',
+  '//lib/log:api',
+  '//lib/log:log4j',
+  '//lib/mina:sshd',
+  '//lib/ow2:ow2-asm',
+  '//lib/ow2:ow2-asm-analysis',
+  '//lib/ow2:ow2-asm-commons',
+  '//lib/ow2:ow2-asm-util',
+  '//lib:args4j',
+  '//lib:blame-cache',
+  '//lib:guava',
+  '//lib:gson',
+  '//lib:gwtorm',
+  '//lib:icu4j',
+  '//lib:jsch',
+  '//lib:mime-util',
+  '//lib:protobuf',
+  '//lib:servlet-api-3_1',
+  '//lib:soy',
+  '//lib:velocity',
+]
+
 java_binary(
   name = 'plugin-api',
   main_class = 'Dummy',
@@ -20,7 +59,42 @@
 
 java_library(
   name = 'lib',
-  exports = PLUGIN_API + [
+  exports = PLUGIN_API + EXPORTS,
+  visibility = ['//visibility:public'],
+)
+
+java_library(
+  name = 'lib-neverlink',
+  neverlink = 1,
+  exports = PLUGIN_API + EXPORTS,
+  visibility = ['//visibility:public'],
+)
+
+java_binary(
+  name = 'plugin-api-sources',
+  main_class = 'Dummy',
+  runtime_deps = [
+    '//gerrit-antlr:libquery_exception-src.jar',
+    '//gerrit-antlr:libquery_parser-src.jar',
+    '//gerrit-common:libannotations-src.jar',
+    '//gerrit-extension-api:libapi-src.jar',
+    '//gerrit-gwtexpui:libserver-src.jar',
+    '//gerrit-httpd:libhttpd-src.jar',
+    '//gerrit-pgm:libinit-api-src.jar',
+    '//gerrit-reviewdb:libserver-src.jar',
+    '//gerrit-server:libserver-src.jar',
+    '//gerrit-sshd:libsshd-src.jar',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+load('//tools/bzl:javadoc.bzl', 'java_doc')
+
+java_doc(
+  name = 'plugin-api-javadoc',
+  title = 'Gerrit Review Plugin API Documentation',
+  pkgs = ['com.google.gerrit'],
+  libs = PLUGIN_API + [
     '//gerrit-antlr:query_exception',
     '//gerrit-antlr:query_parser',
     '//gerrit-common:annotations',
@@ -28,24 +102,6 @@
     '//gerrit-extension-api:api',
     '//gerrit-gwtexpui:server',
     '//gerrit-reviewdb:server',
-    '//lib:args4j',
-    '//lib:blame-cache',
-    '//lib/dropwizard:dropwizard-core',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib:jsch',
-    '//lib:mime-util',
-    '//lib:servlet-api-3_1',
-    '//lib:velocity',
-    '//lib/commons:lang',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet',
-    '//lib/joda:joda-time',
-    '//lib/log:api',
-    '//lib/mina:sshd',
   ],
   visibility = ['//visibility:public'],
 )
diff --git a/gerrit-plugin-api/pom.xml b/gerrit-plugin-api/pom.xml
index f98574c..9309921 100644
--- a/gerrit-plugin-api/pom.xml
+++ b/gerrit-plugin-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>2.13.2</version>
+  <version>2.14-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/gerrit-plugin-archetype/pom.xml b/gerrit-plugin-archetype/pom.xml
index 4da0a42..e69da7c 100644
--- a/gerrit-plugin-archetype/pom.xml
+++ b/gerrit-plugin-archetype/pom.xml
@@ -20,7 +20,7 @@
 
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-archetype</artifactId>
-  <version>2.13.2</version>
+  <version>2.14-SNAPSHOT</version>
   <name>Gerrit Code Review - Plugin Archetype</name>
   <description>Maven Archetype for Gerrit Plugins</description>
   <url>https://www.gerritcodereview.com/</url>
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs b/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
index 2a585e4..602b029 100644
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
+++ b/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
@@ -4,8 +4,8 @@
 org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.eclipse.jdt.annotation.NonNullByDefault
 org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jdt.annotation.Nullable
 org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
-org.eclipse.jdt.core.compiler.compliance=1.7
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
+org.eclipse.jdt.core.compiler.compliance=1.8
 org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=ignore
 org.eclipse.jdt.core.compiler.problem.autoboxing=ignore
 org.eclipse.jdt.core.compiler.problem.comparingIdentical=warning
@@ -85,7 +85,7 @@
 org.eclipse.jdt.core.compiler.problem.unusedPrivateMember=warning
 org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
-org.eclipse.jdt.core.compiler.source=1.7
+org.eclipse.jdt.core.compiler.source=1.8
 org.eclipse.jdt.core.formatter.align_type_members_on_columns=false
 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=16
 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant=16
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml b/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml
index 026e21d..f0cc120 100644
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml
+++ b/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml
@@ -66,8 +66,8 @@
         <artifactId>maven-compiler-plugin</artifactId>
         <version>2.3.2</version>
         <configuration>
-          <source>1.7</source>
-          <target>1.7</target>
+          <source>1.8</source>
+          <target>1.8</target>
           <encoding>UTF-8</encoding>
         </configuration>
       </plugin>
diff --git a/gerrit-plugin-gwt-archetype/pom.xml b/gerrit-plugin-gwt-archetype/pom.xml
index 5a4f712..5e9bc33 100644
--- a/gerrit-plugin-gwt-archetype/pom.xml
+++ b/gerrit-plugin-gwt-archetype/pom.xml
@@ -20,7 +20,7 @@
 
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-gwt-archetype</artifactId>
-  <version>2.13.2</version>
+  <version>2.14-SNAPSHOT</version>
   <name>Gerrit Code Review - Web UI GWT Plugin Archetype</name>
   <description>Maven Archetype for Gerrit Web UI GWT Plugins</description>
   <url>https://www.gerritcodereview.com/</url>
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
index 2a585e4..602b029 100644
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
+++ b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
@@ -4,8 +4,8 @@
 org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.eclipse.jdt.annotation.NonNullByDefault
 org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jdt.annotation.Nullable
 org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
-org.eclipse.jdt.core.compiler.compliance=1.7
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
+org.eclipse.jdt.core.compiler.compliance=1.8
 org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=ignore
 org.eclipse.jdt.core.compiler.problem.autoboxing=ignore
 org.eclipse.jdt.core.compiler.problem.comparingIdentical=warning
@@ -85,7 +85,7 @@
 org.eclipse.jdt.core.compiler.problem.unusedPrivateMember=warning
 org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
-org.eclipse.jdt.core.compiler.source=1.7
+org.eclipse.jdt.core.compiler.source=1.8
 org.eclipse.jdt.core.formatter.align_type_members_on_columns=false
 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=16
 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant=16
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/gwt/BUCK b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/gwt/BUCK
index 511a8ec..21bc45c 100644
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/gwt/BUCK
+++ b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/gwt/BUCK
@@ -13,20 +13,5 @@
   name = 'dev',
   id = 'com.google.gwt:gwt-dev:' + VERSION,
   license = 'Apache2.0',
-  deps = [
-    ':javax-validation',
-    ':javax-validation_src',
-  ],
   attach_source = False,
-  exclude = ['org/eclipse/jetty/*'],
 )
-
-maven_jar(
-  name = 'javax-validation',
-  id = 'javax.validation:validation-api:1.0.0.GA',
-  bin_sha1 = 'b6bd7f9d78f6fdaa3c37dae18a4bd298915f328e',
-  src_sha1 = '7a561191db2203550fbfa40d534d4997624cd369',
-  license = 'Apache2.0',
-  visibility = [],
-)
-
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml
index 2c7fe88..baec648 100644
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml
+++ b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml
@@ -61,8 +61,8 @@
         <artifactId>maven-compiler-plugin</artifactId>
         <version>2.3.2</version>
         <configuration>
-          <source>1.7</source>
-          <target>1.7</target>
+          <source>1.8</source>
+          <target>1.8</target>
           <encoding>UTF-8</encoding>
         </configuration>
       </plugin>
diff --git a/gerrit-plugin-gwtui/BUCK b/gerrit-plugin-gwtui/BUCK
index 2ee0e19..575ebfc 100644
--- a/gerrit-plugin-gwtui/BUCK
+++ b/gerrit-plugin-gwtui/BUCK
@@ -1,8 +1,4 @@
-COMMON = ['gerrit-gwtui-common/src/main/java/']
-GWTEXPUI = ['gerrit-gwtexpui/src/main/java/']
-SRC = 'src/main/java/com/google/gerrit/'
-SRCS = glob([SRC + '**/*.java'])
-
+SRCS = glob(['src/main/java/com/google/gerrit/**/*.java'])
 DEPS = ['//lib/gwt:user']
 
 java_binary(
@@ -50,7 +46,7 @@
     'com.google.gwtexpui.safehtml',
     'com.google.gwtexpui.user',
   ],
-  paths = COMMON + GWTEXPUI,
+  source_jar = ':gwtui-api-src',
   srcs = SRCS,
   deps = DEPS + [
     '//lib:gwtjsonrpc',
@@ -61,5 +57,4 @@
     '//gerrit-reviewdb:client',
   ],
   visibility = ['PUBLIC'],
-  do_it_wrong = True,
 )
diff --git a/gerrit-plugin-gwtui/BUILD b/gerrit-plugin-gwtui/BUILD
new file mode 100644
index 0000000..ac1909f
--- /dev/null
+++ b/gerrit-plugin-gwtui/BUILD
@@ -0,0 +1,55 @@
+package(default_visibility = ['//visibility:public'])
+load('//tools/bzl:java.bzl', 'java_library2')
+
+SRCS = glob(['src/main/java/com/google/gerrit/**/*.java'])
+DEPS = ['//lib/gwt:user']
+
+java_binary(
+  name = 'gwtui-api',
+  main_class = 'Dummy',
+  runtime_deps = [
+    ':gwtui-api-lib',
+    '//gerrit-gwtui-common:client-lib',
+  ],
+)
+
+java_library2(
+  name = 'gwtui-api-lib',
+  srcs = SRCS,
+  resources = glob(['src/main/**/*']),
+  exported_deps = ['//gerrit-gwtui-common:client-lib'],
+  deps = DEPS + ['//lib/gwt:dev'], # we want this to be exported deps
+)
+
+java_binary(
+  name = 'gwtui-api-source',
+  main_class = 'Dummy',
+  runtime_deps = [
+    ':libgwtui-api-lib-src.jar',
+    '//gerrit-gwtexpui:client-src-lib',
+    '//gerrit-gwtui-common:libclient-lib-src.jar',
+  ],
+)
+
+load('//tools/bzl:javadoc.bzl', 'java_doc')
+
+java_doc(
+  name = 'gwtui-api-javadoc',
+  title = 'Gerrit Review GWT Extension API Documentation',
+  pkgs = [
+    'com.google.gerrit.plugin',
+    'com.google.gwtexpui.clippy',
+    'com.google.gwtexpui.globalkey',
+    'com.google.gwtexpui.safehtml',
+    'com.google.gwtexpui.user',
+  ],
+  libs = DEPS + [
+    ':gwtui-api-lib',
+    '//lib:gwtjsonrpc',
+    '//lib:gwtorm_client',
+    '//lib/gwt:dev',
+    '//gerrit-gwtui-common:client-lib',
+    '//gerrit-common:client',
+    '//gerrit-reviewdb:client',
+  ],
+)
diff --git a/gerrit-plugin-gwtui/pom.xml b/gerrit-plugin-gwtui/pom.xml
index 86b9e8f..4b104c6 100644
--- a/gerrit-plugin-gwtui/pom.xml
+++ b/gerrit-plugin-gwtui/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-gwtui</artifactId>
-  <version>2.13.2</version>
+  <version>2.14-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin GWT UI</name>
   <description>Common Classes for Gerrit GWT UI Plugins</description>
diff --git a/gerrit-plugin-js-archetype/pom.xml b/gerrit-plugin-js-archetype/pom.xml
index 4aa2528..d31b455 100644
--- a/gerrit-plugin-js-archetype/pom.xml
+++ b/gerrit-plugin-js-archetype/pom.xml
@@ -20,7 +20,7 @@
 
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-js-archetype</artifactId>
-  <version>2.13.2</version>
+  <version>2.14-SNAPSHOT</version>
   <name>Gerrit Code Review - Web UI JavaScript Plugin Archetype</name>
   <description>Maven Archetype for Gerrit Web UI JavaScript Plugins</description>
   <url>https://www.gerritcodereview.com/</url>
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
index 2a585e4..602b029 100644
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
+++ b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
@@ -4,8 +4,8 @@
 org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.eclipse.jdt.annotation.NonNullByDefault
 org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jdt.annotation.Nullable
 org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
-org.eclipse.jdt.core.compiler.compliance=1.7
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
+org.eclipse.jdt.core.compiler.compliance=1.8
 org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=ignore
 org.eclipse.jdt.core.compiler.problem.autoboxing=ignore
 org.eclipse.jdt.core.compiler.problem.comparingIdentical=warning
@@ -85,7 +85,7 @@
 org.eclipse.jdt.core.compiler.problem.unusedPrivateMember=warning
 org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
-org.eclipse.jdt.core.compiler.source=1.7
+org.eclipse.jdt.core.compiler.source=1.8
 org.eclipse.jdt.core.formatter.align_type_members_on_columns=false
 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=16
 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant=16
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml
index 8f4aadd..7a38260 100644
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml
+++ b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml
@@ -60,8 +60,8 @@
         <artifactId>maven-compiler-plugin</artifactId>
         <version>2.3.2</version>
         <configuration>
-          <source>1.7</source>
-          <target>1.7</target>
+          <source>1.8</source>
+          <target>1.8</target>
           <encoding>UTF-8</encoding>
         </configuration>
       </plugin>
diff --git a/gerrit-prettify/BUILD b/gerrit-prettify/BUILD
index 063feee..b8d4dd6 100644
--- a/gerrit-prettify/BUILD
+++ b/gerrit-prettify/BUILD
@@ -33,3 +33,8 @@
   ],
   visibility = ['//visibility:public'],
 )
+
+exports_files([
+  'src/main/resources/com/google/gerrit/prettify/client/prettify.css',
+  'src/main/resources/com/google/gerrit/prettify/client/prettify.js',
+])
diff --git a/gerrit-reviewdb/BUCK b/gerrit-reviewdb/BUCK
index 82e0135..a5fb1f5 100644
--- a/gerrit-reviewdb/BUCK
+++ b/gerrit-reviewdb/BUCK
@@ -33,6 +33,5 @@
     '//lib:gwtorm',
     '//lib:truth',
   ],
-  source_under_test = [':client'],
   visibility = ['//tools/eclipse:classpath'],
 )
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
index 9e36fc1..de2134b 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
@@ -55,10 +55,6 @@
  * </ul>
  */
 public final class Account {
-  public enum FieldName {
-    FULL_NAME, USER_NAME, REGISTER_NEW_EMAIL
-  }
-
   public static final String USER_NAME_PATTERN_FIRST = "[a-zA-Z0-9]";
   public static final String USER_NAME_PATTERN_REST = "[a-zA-Z0-9._-]";
   public static final String USER_NAME_PATTERN_LAST = "[a-zA-Z0-9]";
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
index 41336791..5ae8847 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.StringKey;
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
index 1864c56..0f3d45c 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
@@ -149,6 +149,7 @@
       }
       int ce = nextNonDigit(ref, cs);
       if (ref.substring(ce).equals(RefNames.META_SUFFIX)
+          || ref.substring(ce).equals(RefNames.ROBOT_COMMENTS_SUFFIX)
           || PatchSet.Id.fromRef(ref, ce) >= 0) {
         return new Change.Id(Integer.parseInt(ref.substring(cs, ce)));
       }
@@ -481,6 +482,13 @@
   @Column(id = 18, notNull = false)
   protected String submissionId;
 
+  /**
+   * Allows assigning a change to a user.
+   */
+  @Column(id = 19, notNull = false)
+  protected Account.Id assignee;
+
+
   /** @see com.google.gerrit.server.notedb.NoteDbChangeState */
   @Column(id = 101, notNull = false, length = Integer.MAX_VALUE)
   protected String noteDbState;
@@ -500,6 +508,7 @@
   }
 
   public Change(Change other) {
+    assignee = other.assignee;
     changeId = other.changeId;
     changeKey = other.changeKey;
     rowVersion = other.rowVersion;
@@ -535,6 +544,14 @@
     changeKey = k;
   }
 
+  public Account.Id getAssignee() {
+    return assignee;
+  }
+
+  public void setAssignee(Account.Id a) {
+    assignee = a;
+  }
+
   public Timestamp getCreatedOn() {
     return createdOn;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
index 898dc94..db44d33 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
@@ -18,6 +18,7 @@
 import com.google.gwtorm.client.StringKey;
 
 import java.sql.Timestamp;
+import java.util.Objects;
 
 /** A message attached to a {@link Change}. */
 public final class ChangeMessage {
@@ -78,6 +79,13 @@
   @Column(id = 6, notNull = false)
   protected String tag;
 
+  /**
+   * Real user that added this message on behalf of the user recorded in {@link
+   * #author}.
+   */
+  @Column(id = 7, notNull = false)
+  protected Account.Id realAuthor;
+
   protected ChangeMessage() {
   }
 
@@ -105,6 +113,15 @@
     author = accountId;
   }
 
+  public Account.Id getRealAuthor() {
+    return realAuthor != null ? realAuthor : getAuthor();
+  }
+
+  public void setRealAuthor(Account.Id id) {
+    // Use null for same real author, as before the column was added.
+    realAuthor = Objects.equals(getAuthor(), id) ? null : id;
+  }
+
   public Timestamp getWrittenOn() {
     return writtenOn;
   }
@@ -142,6 +159,7 @@
     return "ChangeMessage{"
         + "key=" + key
         + ", author=" + author
+        + ", realAuthor=" + realAuthor
         + ", writtenOn=" + writtenOn
         + ", patchset=" + patchset
         + ", tag=" + tag
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
new file mode 100644
index 0000000..5ec3e47
--- /dev/null
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
@@ -0,0 +1,279 @@
+// Copyright (C) 2016 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.reviewdb.client;
+
+import java.sql.Timestamp;
+import java.util.Objects;
+
+/**
+ * This class represents inline comments in NoteDb. This means it determines the
+ * JSON format for inline comments in the revision notes that NoteDb uses to
+ * persist inline comments.
+ * <p>
+ * Changing fields in this class changes the storage format of inline comments
+ * in NoteDb and may require a corresponding data migration (adding new optional
+ * fields is generally okay).
+ * <p>
+ * {@link PatchLineComment} also represents inline comments, but in ReviewDb.
+ * There are a few notable differences:
+ * <ul>
+ * <li>PatchLineComment knows the comment status (published or draft). For
+ * comments in NoteDb the status is determined by the branch in which they are
+ * stored (published comments are stored in the change meta ref; draft comments
+ * are store in refs/draft-comments branches in All-Users). Hence Comment
+ * doesn't need to contain the status, but the status is implicitly known by
+ * where the comments are read from.
+ * <li>PatchLineComment knows the change ID. For comments in NoteDb, the change
+ * ID is determined by the branch in which they are stored (the ref name
+ * contains the change ID). Hence Comment doesn't need to contain the change ID,
+ * but the change ID is implicitly known by where the comments are read from.
+ * </ul>
+ * <p>
+ * For all utility classes and middle layer functionality using Comment over
+ * PatchLineComment is preferred, as PatchLineComment will go away together with
+ * ReviewDb. This means Comment should be used everywhere and only for storing
+ * inline comment in ReviewDb a conversion to PatchLineComment is done.
+ * Converting Comments to PatchLineComments and vice verse is done by
+ * CommentsUtil#toPatchLineComments(Change.Id, PatchLineComment.Status, Iterable)
+ * and CommentsUtil#toComments(String, Iterable).
+ */
+public class Comment {
+  public static class Key {
+    public String uuid;
+    public String filename;
+    public int patchSetId;
+
+    public Key(Key k) {
+      this(k.uuid, k.filename, k.patchSetId);
+    }
+
+    public Key(String uuid, String filename, int patchSetId) {
+      this.uuid = uuid;
+      this.filename = filename;
+      this.patchSetId = patchSetId;
+    }
+
+    @Override
+    public String toString() {
+      return new StringBuilder()
+          .append("Comment.Key{")
+          .append("uuid=").append(uuid).append(',')
+          .append("filename=").append(filename).append(',')
+          .append("patchSetId=").append(patchSetId)
+          .append('}')
+          .toString();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Key) {
+        Key k = (Key) o;
+        return Objects.equals(uuid, k.uuid)
+            && Objects.equals(filename, k.filename)
+            && Objects.equals(patchSetId, k.patchSetId);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(uuid, filename, patchSetId);
+    }
+  }
+
+  public static class Identity {
+    int id;
+
+    public Identity(Account.Id id) {
+      this.id = id.get();
+    }
+
+    public Account.Id getId() {
+      return new Account.Id(id);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Identity) {
+        return Objects.equals(id, ((Identity) o).id);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(id);
+    }
+
+    @Override
+    public String toString() {
+      return new StringBuilder()
+          .append("Comment.Identity{")
+          .append("id=").append(id)
+          .append('}')
+          .toString();
+    }
+  }
+
+  public static class Range {
+    public int startLine;
+    public int startChar;
+    public int endLine;
+    public int endChar;
+
+    public Range(Range r) {
+      this(r.startLine, r.startChar, r.endLine, r.endChar);
+    }
+
+    public Range(com.google.gerrit.extensions.client.Comment.Range r) {
+      this(r.startLine, r.startCharacter, r.endLine, r.endCharacter);
+    }
+
+    public Range(int startLine, int startChar, int endLine, int endChar) {
+      this.startLine = startLine;
+      this.startChar = startChar;
+      this.endLine = endLine;
+      this.endChar = endChar;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Range) {
+        Range r = (Range) o;
+        return Objects.equals(startLine, r.startLine)
+            && Objects.equals(startChar, r.startChar)
+            && Objects.equals(endLine, r.endLine)
+            && Objects.equals(endChar, r.endChar);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(startLine, startChar, endLine, endChar);
+    }
+
+    @Override
+    public String toString() {
+      return new StringBuilder()
+          .append("Comment.Range{")
+          .append("startLine=").append(startLine).append(',')
+          .append("startChar=").append(startChar).append(',')
+          .append("endLine=").append(endLine).append(',')
+          .append("endChar=").append(endChar)
+          .append('}')
+          .toString();
+    }
+  }
+
+  public Key key;
+  public int lineNbr;
+  public Identity author;
+  protected Identity realAuthor;
+  public Timestamp writtenOn;
+  public short side;
+  public String message;
+  public String parentUuid;
+  public Range range;
+  public String tag;
+  public String revId;
+  public String serverId;
+
+  public Comment(Comment c) {
+    this(new Key(c.key), c.author.getId(), new Timestamp(c.writtenOn.getTime()),
+        c.side, c.message, c.serverId);
+    this.lineNbr = c.lineNbr;
+    this.realAuthor = c.realAuthor;
+    this.range = c.range != null ? new Range(c.range) : null;
+    this.tag = c.tag;
+    this.revId = c.revId;
+  }
+
+  public Comment(Key key, Account.Id author, Timestamp writtenOn,
+      short side, String message, String serverId) {
+    this.key = key;
+    this.author = new Comment.Identity(author);
+    this.realAuthor = this.author;
+    this.writtenOn = writtenOn;
+    this.side = side;
+    this.message = message;
+    this.serverId = serverId;
+  }
+
+  public void setLineNbrAndRange(Integer lineNbr,
+      com.google.gerrit.extensions.client.Comment.Range range) {
+    this.lineNbr = lineNbr != null
+        ? lineNbr
+        : range != null
+            ? range.endLine
+            : 0;
+    if (range != null) {
+      this.range = new Comment.Range(range);
+    }
+  }
+
+  public void setRange(CommentRange range) {
+    this.range = range != null ? range.asCommentRange() : null;
+  }
+
+  public void setRevId(RevId revId) {
+    this.revId = revId != null ? revId.get() : null;
+  }
+
+  public void setRealAuthor(Account.Id id) {
+    realAuthor = id != null && id.get() != author.id
+        ? new Comment.Identity(id)
+        : null;
+  }
+
+  public Identity getRealAuthor() {
+    return realAuthor != null ? realAuthor : author;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof Comment) {
+      return Objects.equals(key, ((Comment) o).key);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return key.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return new StringBuilder()
+        .append("Comment{")
+        .append("key=").append(key).append(',')
+        .append("lineNbr=").append(lineNbr).append(',')
+        .append("author=").append(author.getId().get()).append(',')
+        .append("realAuthor=")
+            .append(realAuthor != null ? realAuthor.getId().get() : "")
+            .append(',')
+        .append("writtenOn=").append(writtenOn.toString()).append(',')
+        .append("side=").append(side).append(',')
+        .append("message=").append(Objects.toString(message, "")).append(',')
+        .append("parentUuid=")
+            .append(Objects.toString(parentUuid, "")).append(',')
+        .append("range=").append(Objects.toString(range, "")).append(',')
+        .append("revId=").append(revId != null ? revId : "")
+        .append("tag=").append(Objects.toString(tag, ""))
+        .append('}')
+        .toString();
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java
index 5a98d94..0cc3e58 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java
@@ -72,6 +72,10 @@
     endCharacter = ec;
   }
 
+  public Comment.Range asCommentRange() {
+    return new Comment.Range(startLine, startCharacter, endLine, endCharacter);
+  }
+
   @Override
   public boolean equals(Object obj) {
     if (obj instanceof CommentRange) {
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
index 6a55965..309bda4 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
@@ -22,6 +22,22 @@
   /** Magical file name which represents the commit message. */
   public static final String COMMIT_MSG = "/COMMIT_MSG";
 
+  /** Magical file name which represents the merge list of a merge commit. */
+  public static final String MERGE_LIST = "/MERGE_LIST";
+
+  /**
+   * Checks if the given path represents a magic file. A magic file is a
+   * generated file that is automatically included into changes. It does not
+   * exist in the commit of the patch set.
+   *
+   * @param path the file path
+   * @return {@code true} if the path represents a magic file, otherwise
+   *         {@code false}.
+   */
+  public static boolean isMagic(String path) {
+    return COMMIT_MSG.equals(path) || MERGE_LIST.equals(path);
+  }
+
   public static class Key extends StringKey<PatchSet.Id> {
     private static final long serialVersionUID = 1L;
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
index 16b2d61..5d2f3bb 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
@@ -21,11 +21,24 @@
 import java.sql.Timestamp;
 import java.util.Objects;
 
-/** A comment left by a user on a specific line of a {@link Patch}. */
+/**
+ * A comment left by a user on a specific line of a {@link Patch}.
+ *
+ * This class represents an inline comment in ReviewDb. It should only be used
+ * for writing/reading inline comments to/from ReviewDb. For all other purposes
+ * inline comments should be represented by {@link Comment}.
+ *
+ * @see Comment
+ */
 public final class PatchLineComment {
   public static class Key extends StringKey<Patch.Key> {
     private static final long serialVersionUID = 1L;
 
+    public static Key from(Change.Id changeId, Comment.Key key) {
+      return new Key(new Patch.Key(new PatchSet.Id(changeId, key.patchSetId),
+          key.filename), key.uuid);
+    }
+
     @Column(id = 1, name = Column.NONE)
     protected Patch.Key patchKey;
 
@@ -55,6 +68,12 @@
     public void set(String newValue) {
       uuid = newValue;
     }
+
+    public Comment.Key asCommentKey() {
+      return new Comment.Key(get(),
+          getParentKey().getFileName(),
+          getParentKey().getParentKey().get());
+    }
   }
 
   public static final char STATUS_DRAFT = 'd';
@@ -85,6 +104,29 @@
     }
   }
 
+  public static PatchLineComment from(Change.Id changeId,
+      PatchLineComment.Status status, Comment c) {
+    PatchLineComment.Key key = new PatchLineComment.Key(
+        new Patch.Key(new PatchSet.Id(changeId, c.key.patchSetId),
+            c.key.filename),
+        c.key.uuid);
+
+    PatchLineComment plc = new PatchLineComment(key, c.lineNbr,
+        c.author.getId(), c.parentUuid, c.writtenOn);
+    plc.setSide(c.side);
+    plc.setMessage(c.message);
+    if (c.range != null) {
+      Comment.Range r = c.range;
+      plc.setRange(
+          new CommentRange(r.startLine, r.startChar, r.endLine, r.endChar));
+    }
+    plc.setTag(c.tag);
+    plc.setRevId(new RevId(c.revId));
+    plc.setStatus(status);
+    plc.setRealAuthor(c.getRealAuthor().getId());
+    return plc;
+  }
+
   @Column(id = 1, name = Column.NONE)
   protected Key key;
 
@@ -126,6 +168,13 @@
   protected String tag;
 
   /**
+   * Real user that added this comment on behalf of the user recorded in {@link
+   * #author}.
+   */
+  @Column(id = 11, notNull = false)
+  protected Account.Id realAuthor;
+
+  /**
    * The RevId for the commit to which this comment is referring.
    *
    * Note that this field is not stored in the database. It is just provided
@@ -151,6 +200,7 @@
     key = o.key;
     lineNbr = o.lineNbr;
     author = o.author;
+    realAuthor = o.realAuthor;
     writtenOn = o.writtenOn;
     status = o.status;
     side = o.side;
@@ -186,6 +236,15 @@
     return author;
   }
 
+  public Account.Id getRealAuthor() {
+    return realAuthor != null ? realAuthor : getAuthor();
+  }
+
+  public void setRealAuthor(Account.Id id) {
+    // Use null for same real author, as before the column was added.
+    realAuthor = Objects.equals(getAuthor(), id) ? null : id;
+  }
+
   public Timestamp getWrittenOn() {
     return writtenOn;
   }
@@ -260,6 +319,18 @@
     return tag;
   }
 
+  public Comment asComment(String serverId) {
+    Comment c = new Comment(key.asCommentKey(), author, writtenOn, side,
+        message, serverId);
+    c.setRevId(revId);
+    c.setRange(range);
+    c.lineNbr = lineNbr;
+    c.parentUuid = parentUuid;
+    c.tag = tag;
+    c.setRealAuthor(getRealAuthor());
+    return c;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof PatchLineComment) {
@@ -291,6 +362,8 @@
     builder.append("key=").append(key).append(',');
     builder.append("lineNbr=").append(lineNbr).append(',');
     builder.append("author=").append(author.get()).append(',');
+    builder.append("realAuthor=")
+        .append(realAuthor != null ? realAuthor.get() : "").append(',');
     builder.append("writtenOn=").append(writtenOn.toString()).append(',');
     builder.append("status=").append(status).append(',');
     builder.append("side=").append(side).append(',');
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
index a8bf07b..2210319 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
@@ -201,6 +201,16 @@
     id = k;
   }
 
+  public PatchSet(PatchSet src) {
+    this.id = src.id;
+    this.revision = src.revision;
+    this.uploader = src.uploader;
+    this.createdOn = src.createdOn;
+    this.draft = src.draft;
+    this.groups = src.groups;
+    this.pushCertificate = src.pushCertificate;
+  }
+
   public PatchSet.Id getId() {
     return id;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
index b9cd813..30f2e1d 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
@@ -93,6 +93,16 @@
   @Column(id = 6, notNull = false)
   protected String tag;
 
+  /**
+   * Real user that made this approval on behalf of the user recorded in {@link
+   * Key#accountId}.
+   */
+  @Column(id = 7, notNull = false)
+  protected Account.Id realAccountId;
+
+  @Column(id = 8)
+  protected boolean postSubmit;
+
   // DELETED: id = 4 (changeOpen)
   // DELETED: id = 5 (changeSortKey)
 
@@ -110,7 +120,13 @@
         new PatchSetApproval.Key(psId, src.getAccountId(), src.getLabelId());
     value = src.getValue();
     granted = src.granted;
+    realAccountId = src.realAccountId;
     tag = src.tag;
+    postSubmit = src.postSubmit;
+  }
+
+  public PatchSetApproval(PatchSetApproval src) {
+    this(src.getPatchSetId(), src);
   }
 
   public PatchSetApproval.Key getKey() {
@@ -125,6 +141,15 @@
     return key.accountId;
   }
 
+  public Account.Id getRealAccountId() {
+    return realAccountId != null ? realAccountId : getAccountId();
+  }
+
+  public void setRealAccountId(Account.Id id) {
+    // Use null for same real author, as before the column was added.
+    realAccountId = Objects.equals(getAccountId(), id) ? null : id;
+  }
+
   public LabelId getLabelId() {
     return key.categoryId;
   }
@@ -165,10 +190,24 @@
     return tag;
   }
 
+  public void setPostSubmit(boolean postSubmit) {
+    this.postSubmit = postSubmit;
+  }
+
+  public boolean isPostSubmit() {
+    return postSubmit;
+  }
+
   @Override
   public String toString() {
-    return new StringBuilder().append('[').append(key).append(": ")
-        .append(value).append(",tag:").append(tag).append(']').toString();
+    StringBuilder sb = new StringBuilder("[")
+        .append(key).append(": ").append(value)
+        .append(",tag:").append(tag)
+        .append(",realAccountId:").append(realAccountId);
+    if (postSubmit) {
+      sb.append(",postSubmit");
+    }
+    return sb.append(']').toString();
   }
 
   @Override
@@ -178,7 +217,9 @@
       return Objects.equals(key, p.key)
           && Objects.equals(value, p.value)
           && Objects.equals(granted, p.granted)
-          && Objects.equals(tag, p.tag);
+          && Objects.equals(tag, p.tag)
+          && Objects.equals(realAccountId, p.realAccountId)
+          && postSubmit == p.postSubmit;
     }
     return false;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index b2bd818..c3aff5b 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -68,6 +68,9 @@
   /** Suffix of a meta ref in the NoteDb. */
   public static final String META_SUFFIX = "/meta";
 
+  /** Suffix of a ref that stores robot comments in the NoteDb. */
+  public static final String ROBOT_COMMENTS_SUFFIX = "/robot-comments";
+
   public static final String EDIT_PREFIX = "edit-";
 
   public static String fullName(String ref) {
@@ -92,6 +95,14 @@
     return r.toString();
   }
 
+  public static String robotCommentsRef(Change.Id id) {
+    StringBuilder r = new StringBuilder();
+    r.append(REFS_CHANGES);
+    r.append(shard(id.get()));
+    r.append(ROBOT_COMMENTS_SUFFIX);
+    return r.toString();
+  }
+
   public static String refsUsers(Account.Id accountId) {
     StringBuilder r = new StringBuilder();
     r.append(REFS_USERS);
@@ -129,6 +140,13 @@
     return r;
   }
 
+  public static String refsCacheAutomerge(String hash) {
+    return REFS_CACHE_AUTOMERGE
+        + hash.substring(0, 2)
+        + '/'
+        + hash.substring(2);
+  }
+
   public static String shard(int id) {
     if (id < 0) {
       return null;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RobotComment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RobotComment.java
new file mode 100644
index 0000000..ecb952a
--- /dev/null
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RobotComment.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2016 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.reviewdb.client;
+
+import java.sql.Timestamp;
+import java.util.Map;
+import java.util.Objects;
+
+public class RobotComment extends Comment {
+  public String robotId;
+  public String robotRunId;
+  public String url;
+  public Map<String, String> properties;
+
+  public RobotComment(Key key, Account.Id author, Timestamp writtenOn,
+      short side, String message, String serverId, String robotId,
+      String robotRunId) {
+    super(key, author, writtenOn, side, message, serverId);
+    this.robotId = robotId;
+    this.robotRunId = robotRunId;
+  }
+
+  @Override
+  public String toString() {
+    return new StringBuilder()
+        .append("RobotComment{")
+        .append("key=").append(key).append(',')
+        .append("robotId=").append(robotId).append(',')
+        .append("robotRunId=").append(robotRunId).append(',')
+        .append("lineNbr=").append(lineNbr).append(',')
+        .append("author=").append(author.getId().get()).append(',')
+        .append("realAuthor=")
+            .append(realAuthor != null ? realAuthor.getId().get() : "")
+            .append(',')
+        .append("writtenOn=").append(writtenOn.toString()).append(',')
+        .append("side=").append(side).append(',')
+        .append("message=").append(Objects.toString(message, "")).append(',')
+        .append("parentUuid=")
+            .append(Objects.toString(parentUuid, "")).append(',')
+        .append("range=").append(Objects.toString(range, "")).append(',')
+        .append("revId=").append(revId != null ? revId : "").append(',')
+        .append("tag=").append(Objects.toString(tag, "")).append(',')
+        .append("url=").append(url).append(',')
+        .append("properties=").append(properties != null ? properties : "")
+        .append('}')
+        .toString();
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
index 42d0993..7e2a9b0 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
@@ -14,41 +14,37 @@
 
 package com.google.gerrit.reviewdb.server;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Ordering;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.client.IntKey;
 
 /** Static utilities for ReviewDb types. */
 public class ReviewDbUtil {
-  public static final Function<IntKey<?>, Integer> INT_KEY_FUNCTION =
-      new Function<IntKey<?>, Integer>() {
-        @Override
-        public Integer apply(IntKey<?> in) {
-          return in.get();
-        }
-      };
-
-  private static final Function<Change, Change.Id> CHANGE_ID_FUNCTION =
-      new Function<Change, Change.Id>() {
-        @Override
-        public Change.Id apply(Change in) {
-          return in.getId();
-        }
-      };
-
   private static final Ordering<? extends IntKey<?>> INT_KEY_ORDERING =
-      Ordering.natural().nullsFirst().onResultOf(INT_KEY_FUNCTION).nullsFirst();
+      Ordering.natural()
+          .nullsFirst()
+          .<IntKey<?>>onResultOf(IntKey::get)
+          .nullsFirst();
 
+  /**
+   * Null-safe ordering over arbitrary subclass of {@code IntKey}.
+   * <p>
+   * In some cases, {@code Comparator.comparing(Change.Id::get)} may be shorter
+   * and cleaner. However, this method may be preferable in some cases:
+   * <ul>
+   * <li>This ordering is null-safe over both input and the result of {@link
+   *   IntKey#get()}; {@code comparing} is only a good idea if all inputs are
+   *   obviously non-null.</li>
+   * <li>{@code intKeyOrdering().sortedCopy(iterable)} is shorter than the
+   *   stream equivalent.</li>
+   * <li>Creating derived comparators may be more readable with {@link Ordering}
+   *   method chaining rather than static {@code Comparator} methods.
+   * </ul>
+   */
   @SuppressWarnings("unchecked")
   public static <K extends IntKey<?>> Ordering<K> intKeyOrdering() {
     return (Ordering<K>) INT_KEY_ORDERING;
   }
 
-  public static Function<Change, Change.Id> changeIdFunction() {
-    return CHANGE_ID_FUNCTION;
-  }
-
   public static ReviewDb unwrapDb(ReviewDb db) {
     if (db instanceof DisabledChangesReviewDbWrapper) {
       return ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
diff --git a/gerrit-server/BUCK b/gerrit-server/BUCK
index 4fc578c..a50df82 100644
--- a/gerrit-server/BUCK
+++ b/gerrit-server/BUCK
@@ -46,6 +46,7 @@
     '//lib:mime-util',
     '//lib:pegdown',
     '//lib:protobuf',
+    '//lib:soy',
     '//lib:tukaani-xz',
     '//lib:velocity',
     '//lib/antlr:java_runtime',
@@ -90,6 +91,7 @@
   ':server',
   '//gerrit-common:server',
   '//gerrit-cache-h2:cache-h2',
+  '//gerrit-elasticsearch:elasticsearch',
   '//gerrit-extension-api:api',
   '//gerrit-gpg:gpg',
   '//gerrit-lucene:lucene',
@@ -180,7 +182,7 @@
     '//gerrit-server/src/main/prolog:common',
     '//lib/antlr:java_runtime',
   ],
-  source_under_test = [':server'],
+  visibility = ['PUBLIC'],
 )
 
 java_test(
@@ -203,11 +205,9 @@
     '//lib:guava',
     '//lib:guava-retrying',
     '//lib:protobuf',
-    '//lib/commons:validator',
     '//lib/dropwizard:dropwizard-core',
     '//lib/guice:guice-assistedinject',
     '//lib/prolog:runtime',
   ],
-  source_under_test = [':server'],
   visibility = ['//tools/eclipse:classpath'],
 )
diff --git a/gerrit-server/BUILD b/gerrit-server/BUILD
index 5a6b50f..3874fc9 100644
--- a/gerrit-server/BUILD
+++ b/gerrit-server/BUILD
@@ -48,6 +48,7 @@
     '//lib:pegdown',
     '//lib:protobuf',
     '//lib:servlet-api-3_1',
+    '//lib:soy',
     '//lib:tukaani-xz',
     '//lib:velocity',
     '//lib/antlr:java_runtime',
@@ -168,6 +169,20 @@
   ['src/test/java/com/google/gerrit/server/query/**/*.java'],
 )
 
+java_library(
+  name = 'query_tests_code',
+  srcs = QUERY_TESTS,
+  deps = TESTUTIL_DEPS + [
+    ':testutil',
+    '//gerrit-antlr:query_exception',
+    '//gerrit-antlr:query_parser',
+    '//gerrit-common:annotations',
+    '//gerrit-server/src/main/prolog:common',
+    '//lib/antlr:java_runtime',
+  ],
+  visibility = ['//visibility:public'],
+)
+
 junit_tests(
   name = 'query_tests',
   srcs = QUERY_TESTS,
@@ -188,6 +203,7 @@
     ['src/test/java/**/*.java'],
     exclude = TESTUTIL + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS
   ),
+  resources = glob(['src/test/resources/com/google/gerrit/server/mail/*']),
   deps = TESTUTIL_DEPS + [
     ':testutil',
     '//gerrit-antlr:query_exception',
@@ -206,3 +222,12 @@
   ],
   visibility = ['//visibility:public'],
 )
+
+load('//tools/bzl:javadoc.bzl', 'java_doc')
+
+java_doc(
+  name = 'doc',
+  title = 'Gerrit Review Server Documentation',
+  libs = [':server'],
+  pkgs = ['com.google.gerrit'],
+)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java
index 364f4f8..95fbf04 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java
@@ -161,17 +161,10 @@
   private static <T> Function<T, String> initFormatter(Class<T> keyType) {
     if (keyType == String.class) {
       return (Function<T, String>) Functions.<String> identity();
-
     } else if (keyType == Integer.class || keyType == Boolean.class) {
       return (Function<T, String>) Functions.toStringFunction();
-
     } else if (Enum.class.isAssignableFrom(keyType)) {
-      return new Function<T, String>() {
-        @Override
-        public String apply(T in) {
-          return ((Enum<?>) in).name();
-        }
-      };
+      return in -> ((Enum<?>) in).name();
     }
     throw new IllegalStateException("unsupported type " + keyType.getName());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
index e7ab75c..d3fe6ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import com.google.gerrit.metrics.Description;
@@ -124,14 +123,7 @@
 
   @Override
   public Map<Object, Metric> getCells() {
-    return Maps.transformValues(
-        cells,
-        new Function<ValueGauge, Metric> () {
-          @Override
-          public Metric apply(ValueGauge in) {
-            return in;
-          }
-        });
+    return Maps.transformValues(cells, in -> (Metric) in);
   }
 
   final class ValueGauge implements Gauge<V> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
index 10b92e6..7894a84 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import com.google.gerrit.metrics.Description;
@@ -98,13 +97,6 @@
 
   @Override
   public Map<Object, Metric> getCells() {
-    return Maps.transformValues(
-        cells,
-        new Function<CounterImpl, Metric> () {
-          @Override
-          public Metric apply(CounterImpl in) {
-            return in.metric;
-          }
-        });
+    return Maps.transformValues(cells, c -> c.metric);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
index 071c678..ff38cd4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import com.google.gerrit.metrics.Description;
@@ -96,13 +95,6 @@
 
   @Override
   public Map<Object, Metric> getCells() {
-    return Maps.transformValues(
-        cells,
-        new Function<HistogramImpl, Metric> () {
-          @Override
-          public Metric apply(HistogramImpl in) {
-            return in.metric;
-          }
-        });
+    return Maps.transformValues(cells, h -> h.metric);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
index 6981ef1..aff6c4a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import com.google.gerrit.metrics.Description;
@@ -96,13 +95,6 @@
 
   @Override
   public Map<Object, Metric> getCells() {
-    return Maps.transformValues(
-        cells,
-        new Function<TimerImpl, Metric> () {
-          @Override
-          public Metric apply(TimerImpl in) {
-            return in.metric;
-          }
-        });
+    return Maps.transformValues(cells, t -> t.metric);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
index e159c82..ee2ce29 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
@@ -18,7 +18,6 @@
 import static com.google.gerrit.metrics.dropwizard.MetricResource.METRIC_KIND;
 import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
 
-import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -304,14 +303,8 @@
   @Override
   public synchronized RegistrationHandle newTrigger(
       Set<CallbackMetric<?>> metrics, Runnable trigger) {
-    final ImmutableSet<CallbackMetricGlue> all = FluentIterable.from(metrics)
-        .transform(
-          new Function<CallbackMetric<?>, CallbackMetricGlue>() {
-            @Override
-            public CallbackMetricGlue apply(CallbackMetric<?> input) {
-              return (CallbackMetricGlue) input;
-            }
-          })
+    ImmutableSet<CallbackMetricGlue> all = FluentIterable.from(metrics)
+        .transform(m -> (CallbackMetricGlue) m)
         .toSet();
 
     trigger = new CallbackGroup(trigger, all);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
index bc2ec06..1fcb5b6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
@@ -77,22 +77,54 @@
     this.psUtil = psUtil;
   }
 
+  /**
+   * Apply approval copy settings from prior PatchSets to a new PatchSet.
+   *
+   * @param db review database.
+   * @param ctl change control for user uploading PatchSet
+   * @param ps new PatchSet
+   * @throws OrmException
+   */
   public void copy(ReviewDb db, ChangeControl ctl, PatchSet ps)
       throws OrmException {
-    db.patchSetApprovals().insert(getForPatchSet(db, ctl, ps));
+    copy(db, ctl, ps, Collections.<PatchSetApproval>emptyList());
+  }
+
+  /**
+   * Apply approval copy settings from prior PatchSets to a new PatchSet.
+   *
+   * @param db review database.
+   * @param ctl change control for user uploading PatchSet
+   * @param ps new PatchSet
+   * @param dontCopy PatchSetApprovals indicating which (account, label) pairs
+   *        should not be copied
+   * @throws OrmException
+   */
+  public void copy(ReviewDb db, ChangeControl ctl, PatchSet ps,
+      Iterable<PatchSetApproval> dontCopy) throws OrmException {
+    db.patchSetApprovals().insert(
+        getForPatchSet(db, ctl, ps, dontCopy));
   }
 
   Iterable<PatchSetApproval> getForPatchSet(ReviewDb db,
       ChangeControl ctl, PatchSet.Id psId) throws OrmException {
+    return getForPatchSet(db, ctl, psId,
+        Collections.<PatchSetApproval>emptyList());
+  }
+
+  Iterable<PatchSetApproval> getForPatchSet(ReviewDb db,
+      ChangeControl ctl, PatchSet.Id psId,
+      Iterable<PatchSetApproval> dontCopy) throws OrmException {
     PatchSet ps = psUtil.get(db, ctl.getNotes(), psId);
     if (ps == null) {
       return Collections.emptyList();
     }
-    return getForPatchSet(db, ctl, ps);
+    return getForPatchSet(db, ctl, ps, dontCopy);
   }
 
   private Iterable<PatchSetApproval> getForPatchSet(ReviewDb db,
-      ChangeControl ctl, PatchSet ps) throws OrmException {
+      ChangeControl ctl, PatchSet ps,
+      Iterable<PatchSetApproval> dontCopy) throws OrmException {
     checkNotNull(ps, "ps should not be null");
     ChangeData cd = changeDataFactory.create(db, ctl);
     try {
@@ -103,10 +135,16 @@
 
       Table<String, Account.Id, PatchSetApproval> wontCopy =
           HashBasedTable.create();
+      for (PatchSetApproval psa : dontCopy) {
+        wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
+      }
+
       Table<String, Account.Id, PatchSetApproval> byUser =
           HashBasedTable.create();
       for (PatchSetApproval psa : all.get(ps.getId())) {
-        byUser.put(psa.getLabel(), psa.getAccountId(), psa);
+        if (!wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
+          byUser.put(psa.getLabel(), psa.getAccountId(), psa);
+        }
       }
 
       TreeMap<Integer, PatchSet> patchSets = getPatchSets(cd);
@@ -123,7 +161,8 @@
             continue;
           }
 
-          ChangeKind kind = changeKindCache.getChangeKind(project, repo,
+          ChangeKind kind = changeKindCache.getChangeKind(
+              project.getProject().getNameKey(), repo,
               ObjectId.fromString(priorPs.getRevision().get()),
               ObjectId.fromString(ps.getRevision().get()));
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index e0526e4..67f07bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.Comparator.comparing;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.Predicate;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
@@ -27,6 +27,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
+import com.google.common.primitives.Shorts;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.Permission;
@@ -43,12 +44,15 @@
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
-import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -73,39 +77,50 @@
  */
 @Singleton
 public class ApprovalsUtil {
+  private static final Logger log =
+      LoggerFactory.getLogger(ApprovalsUtil.class);
+
   private static final Ordering<PatchSetApproval> SORT_APPROVALS =
-      Ordering.natural()
-          .onResultOf(
-              new Function<PatchSetApproval, Timestamp>() {
-                @Override
-                public Timestamp apply(PatchSetApproval a) {
-                  return a.getGranted();
-                }
-              });
+      Ordering.from(comparing(PatchSetApproval::getGranted));
 
   public static List<PatchSetApproval> sortApprovals(
       Iterable<PatchSetApproval> approvals) {
     return SORT_APPROVALS.sortedCopy(approvals);
   }
 
+  public static PatchSetApproval newApproval(PatchSet.Id psId, CurrentUser user,
+      LabelId labelId, int value, Date when) {
+    PatchSetApproval psa = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            psId,
+            user.getAccountId(),
+            labelId),
+        Shorts.checkedCast(value),
+        when);
+    user.updateRealAccountId(psa::setRealAccountId);
+    return psa;
+  }
+
   private static Iterable<PatchSetApproval> filterApprovals(
       Iterable<PatchSetApproval> psas, final Account.Id accountId) {
-    return Iterables.filter(psas, new Predicate<PatchSetApproval>() {
-      @Override
-      public boolean apply(PatchSetApproval input) {
-        return Objects.equals(input.getAccountId(), accountId);
-      }
-    });
+    return Iterables.filter(
+        psas, a -> Objects.equals(a.getAccountId(), accountId));
   }
 
   private final NotesMigration migration;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
   private final ApprovalCopier copier;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(NotesMigration migration,
+      IdentifiedUser.GenericFactory userFactory,
+      ChangeControl.GenericFactory changeControlFactory,
       ApprovalCopier copier) {
     this.migration = migration;
+    this.userFactory = userFactory;
+    this.changeControlFactory = changeControlFactory;
     this.copier = copier;
   }
 
@@ -164,8 +179,8 @@
       PatchSetInfo info, Iterable<Account.Id> wantReviewers,
       Collection<Account.Id> existingReviewers) throws OrmException {
     return addReviewers(db, update, labelTypes, change, ps.getId(),
-        ps.isDraft(), info.getAuthor().getAccount(),
-        info.getCommitter().getAccount(), wantReviewers, existingReviewers);
+        info.getAuthor().getAccount(), info.getCommitter().getAccount(),
+        wantReviewers, existingReviewers);
   }
 
   public List<PatchSetApproval> addReviewers(ReviewDb db, ChangeNotes notes,
@@ -189,12 +204,12 @@
         existingReviewers.add(entry.getKey());
       }
     }
-    return addReviewers(db, update, labelTypes, change, psId, false, null, null,
+    return addReviewers(db, update, labelTypes, change, psId, null, null,
         wantReviewers, existingReviewers);
   }
 
   private List<PatchSetApproval> addReviewers(ReviewDb db, ChangeUpdate update,
-      LabelTypes labelTypes, Change change, PatchSet.Id psId, boolean isDraft,
+      LabelTypes labelTypes, Change change, PatchSet.Id psId,
       Account.Id authorId, Account.Id committerId,
       Iterable<Account.Id> wantReviewers,
       Collection<Account.Id> existingReviewers) throws OrmException {
@@ -204,11 +219,11 @@
     }
 
     Set<Account.Id> need = Sets.newLinkedHashSet(wantReviewers);
-    if (authorId != null && !isDraft) {
+    if (authorId != null && canSee(db, update.getNotes(), authorId)) {
       need.add(authorId);
     }
 
-    if (committerId != null && !isDraft) {
+    if (committerId != null && canSee(db, update.getNotes(), committerId)) {
       need.add(committerId);
     }
     need.remove(change.getOwner());
@@ -225,10 +240,21 @@
           (short) 0, update.getWhen()));
       update.putReviewer(account, REVIEWER);
     }
-    db.patchSetApprovals().insert(cells);
+    db.patchSetApprovals().upsert(cells);
     return Collections.unmodifiableList(cells);
   }
 
+  private boolean canSee(ReviewDb db, ChangeNotes notes, Account.Id accountId) {
+    try {
+      IdentifiedUser user = userFactory.create(accountId);
+      return changeControlFactory.controlFor(notes, user).isVisible(db);
+    } catch (OrmException | NoSuchChangeException e) {
+      log.warn(String.format("Failed to check if account %d can see change %d",
+          accountId.get(), notes.getChangeId().get()), e);
+      return false;
+    }
+  }
+
   /**
    * Adds accounts to a change as reviewers in the CC state.
    *
@@ -254,25 +280,42 @@
     return need;
   }
 
-  public void addApprovals(ReviewDb db, ChangeUpdate update,
-      LabelTypes labelTypes, PatchSet ps, ChangeControl changeCtl,
-      Map<String, Short> approvals) throws OrmException {
-    if (!approvals.isEmpty()) {
-      checkApprovals(approvals, changeCtl);
-      List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
-      Date ts = update.getWhen();
-      for (Map.Entry<String, Short> vote : approvals.entrySet()) {
-        LabelType lt = labelTypes.byLabel(vote.getKey());
-        cells.add(new PatchSetApproval(new PatchSetApproval.Key(
-            ps.getId(),
-            ps.getUploader(),
-            lt.getLabelId()),
-            vote.getValue(),
-            ts));
-        update.putApproval(vote.getKey(), vote.getValue());
-      }
-      db.patchSetApprovals().insert(cells);
+  /**
+   * Adds approvals to ChangeUpdate for a new patch set, and writes to ReviewDb.
+   *
+   * @param db review database.
+   * @param update change update.
+   * @param labelTypes label types for the containing project.
+   * @param ps patch set being approved.
+   * @param changeCtl change control for user adding approvals.
+   * @param approvals approvals to add.
+   * @throws OrmException
+   */
+  public Iterable<PatchSetApproval> addApprovalsForNewPatchSet(ReviewDb db,
+      ChangeUpdate update, LabelTypes labelTypes, PatchSet ps,
+      ChangeControl changeCtl, Map<String, Short> approvals)
+      throws OrmException {
+    Account.Id accountId = changeCtl.getUser().getAccountId();
+    checkArgument(accountId.equals(ps.getUploader()),
+        "expected user %s to match patch set uploader %s",
+        accountId, ps.getUploader());
+    if (approvals.isEmpty()) {
+      return Collections.emptyList();
     }
+    checkApprovals(approvals, changeCtl);
+    List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
+    Date ts = update.getWhen();
+    for (Map.Entry<String, Short> vote : approvals.entrySet()) {
+      LabelType lt = labelTypes.byLabel(vote.getKey());
+      cells.add(
+          newApproval(ps.getId(), changeCtl.getUser(), lt.getLabelId(),
+              vote.getValue(), ts));
+    }
+    for (PatchSetApproval psa : cells) {
+      update.putApproval(psa.getLabel(), psa.getValue());
+    }
+    db.patchSetApprovals().insert(cells);
+    return cells;
   }
 
   public static void checkLabel(LabelTypes labelTypes, String name, Short value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
index bc6f732..2cd4df6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.base.Optional;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeTriplet;
@@ -30,6 +29,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 
 @Singleton
 public class ChangeFinder {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
index f3fdbcb..263eca4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -14,12 +14,16 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -27,6 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
@@ -39,6 +44,54 @@
  */
 @Singleton
 public class ChangeMessagesUtil {
+  public final static String TAG_ABANDON =
+      "autogenerated:gerrit:abandon";
+  public final static String TAG_CHERRY_PICK_CHANGE =
+      "autogenerated:gerrit:cherryPickChange";
+  public final static String TAG_DELETE_ASSIGNEE =
+      "autogenerated:gerrit:deleteAssignee";
+  public final static String TAG_DELETE_REVIEWER =
+      "autogenerated:gerrit:deleteReviewer";
+  public final static String TAG_DELETE_VOTE =
+      "autogenerated:gerrit:deleteVote";
+  public final static String TAG_MERGED =
+      "autogenerated:gerrit:merged";
+  public final static String TAG_MOVE =
+      "autogenerated:gerrit:move";
+  public final static String TAG_RESTORE =
+      "autogenerated:gerrit:restore";
+  public final static String TAG_REVERT =
+      "autogenerated:gerrit:revert";
+  public final static String TAG_SET_ASSIGNEE =
+      "autogenerated:gerrit:setAssignee";
+  public final static String TAG_SET_HASHTAGS =
+      "autogenerated:gerrit:setHashtag";
+  public final static String TAG_SET_TOPIC =
+      "autogenerated:gerrit:setTopic";
+  public final static String TAG_UPLOADED_PATCH_SET =
+      "autogenerated:gerrit:newPatchSet";
+
+  public static ChangeMessage newMessage( BatchUpdate.ChangeContext ctx,
+      String body, @Nullable String tag) throws OrmException {
+    return newMessage(
+        ctx.getDb(), ctx.getChange().currentPatchSetId(),
+        ctx.getUser(), ctx.getWhen(), body, tag);
+  }
+
+  public static ChangeMessage newMessage(
+      ReviewDb db, PatchSet.Id psId, CurrentUser user, Timestamp when,
+      String body, @Nullable String tag) throws OrmException {
+    checkNotNull(psId);
+    Account.Id accountId = user.isInternalUser() ? null : user.getAccountId();
+    ChangeMessage m = new ChangeMessage(
+        new ChangeMessage.Key(psId.getParentKey(), ChangeUtil.messageUUID(db)),
+        accountId, when, psId);
+    m.setMessage(body);
+    m.setTag(tag);
+    user.updateRealAccountId(m::setRealAuthor);
+    return m;
+  }
+
   private static List<ChangeMessage> sortChangeMessages(
       Iterable<ChangeMessage> changeMessage) {
     return ChangeNotes.MESSAGE_BY_TIME.sortedCopy(changeMessage);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
index 11a3d81..7866ed3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.base.Function;
+import static java.util.Comparator.comparingInt;
+
 import com.google.common.collect.Ordering;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -40,16 +41,8 @@
   private static final String SUBJECT_CROP_APPENDIX = "...";
   private static final int SUBJECT_CROP_RANGE = 10;
 
-  public static final Function<PatchSet, Integer> TO_PS_ID =
-      new Function<PatchSet, Integer>() {
-        @Override
-        public Integer apply(PatchSet in) {
-          return in.getId().get();
-        }
-      };
-
-  public static final Ordering<PatchSet> PS_ID_ORDER = Ordering.natural()
-    .onResultOf(TO_PS_ID);
+  public static final Ordering<PatchSet> PS_ID_ORDER =
+      Ordering.from(comparingInt(PatchSet::getPatchSetId));
 
   /**
    * Generate a new unique identifier for change message entities.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
new file mode 100644
index 0000000..d1e0ae5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
@@ -0,0 +1,463 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.stream.StreamSupport;
+
+/**
+ * Utility functions to manipulate Comments.
+ * <p>
+ * These methods either query for and update Comments in the NoteDb or
+ * ReviewDb, depending on the state of the NotesMigration.
+ */
+@Singleton
+public class CommentsUtil {
+  public static final Ordering<Comment> COMMENT_ORDER =
+      new Ordering<Comment>() {
+    @Override
+    public int compare(Comment c1, Comment c2) {
+      return ComparisonChain.start()
+          .compare(c1.key.filename, c2.key.filename)
+          .compare(c1.key.patchSetId, c2.key.patchSetId)
+          .compare(c1.side, c2.side)
+          .compare(c1.lineNbr, c2.lineNbr)
+          .compare(c1.writtenOn, c2.writtenOn)
+          .result();
+    }
+  };
+
+  public static final Ordering<CommentInfo> COMMENT_INFO_ORDER =
+      new Ordering<CommentInfo>() {
+        @Override
+        public int compare(CommentInfo a, CommentInfo b) {
+          return ComparisonChain.start()
+              .compare(a.path, b.path, NULLS_FIRST)
+              .compare(a.patchSet, b.patchSet, NULLS_FIRST)
+              .compare(side(a), side(b))
+              .compare(a.line, b.line, NULLS_FIRST)
+              .compare(a.id, b.id)
+              .result();
+        }
+
+        private int side(CommentInfo c) {
+          return firstNonNull(c.side, Side.REVISION).ordinal();
+        }
+      };
+
+  public static PatchSet.Id getCommentPsId(Change.Id changeId,
+      Comment comment) {
+    return new PatchSet.Id(changeId, comment.key.patchSetId);
+  }
+
+  private static final Ordering<Comparable<?>> NULLS_FIRST =
+      Ordering.natural().nullsFirst();
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+  private final NotesMigration migration;
+  private final String serverId;
+
+  @Inject
+  CommentsUtil(GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      NotesMigration migration,
+      @GerritServerId String serverId) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.migration = migration;
+    this.serverId = serverId;
+  }
+
+  public Comment newComment(ChangeContext ctx, String path, PatchSet.Id psId,
+      short side, String message) throws OrmException {
+    Comment c = new Comment(
+        new Comment.Key(ChangeUtil.messageUUID(ctx.getDb()), path, psId.get()),
+        ctx.getUser().getAccountId(), ctx.getWhen(), side, message, serverId);
+    ctx.getUser().updateRealAccountId(c::setRealAuthor);
+    return c;
+  }
+
+  public RobotComment newRobotComment(ChangeContext ctx, String path,
+      PatchSet.Id psId, short side, String message, String robotId,
+      String robotRunId) throws OrmException {
+    RobotComment c = new RobotComment(
+        new Comment.Key(ChangeUtil.messageUUID(ctx.getDb()), path, psId.get()),
+        ctx.getUser().getAccountId(), ctx.getWhen(), side, message, serverId,
+        robotId, robotRunId);
+    ctx.getUser().updateRealAccountId(c::setRealAuthor);
+    return c;
+  }
+
+  public Optional<Comment> get(ReviewDb db, ChangeNotes notes,
+      Comment.Key key) throws OrmException {
+    if (!migration.readChanges()) {
+      return Optional.ofNullable(
+              db.patchComments()
+                  .get(PatchLineComment.Key.from(notes.getChangeId(), key)))
+          .map(plc -> plc.asComment(serverId));
+    }
+    Predicate<Comment> p = c -> key.equals(c.key);
+    Optional<Comment> c =
+        publishedByChange(db, notes).stream().filter(p).findFirst();
+    if (c.isPresent()) {
+      return c;
+    }
+    return draftByChange(db, notes).stream().filter(p).findFirst();
+  }
+
+  public List<Comment> publishedByChange(ReviewDb db, ChangeNotes notes)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(byCommentStatus(
+          db.patchComments().byChange(notes.getChangeId()), Status.PUBLISHED));
+    }
+
+    notes.load();
+    return sort(Lists.newArrayList(notes.getComments().values()));
+  }
+
+  public List<RobotComment> robotCommentsByChange(ChangeNotes notes)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return ImmutableList.of();
+    }
+
+    notes.load();
+    return sort(Lists.newArrayList(notes.getRobotComments().values()));
+  }
+
+  public List<Comment> draftByChange(ReviewDb db, ChangeNotes notes)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(byCommentStatus(
+          db.patchComments().byChange(notes.getChangeId()), Status.DRAFT));
+    }
+
+    List<Comment> comments = new ArrayList<>();
+    for (Ref ref : getDraftRefs(notes.getChangeId())) {
+      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
+      if (account != null) {
+        comments.addAll(draftByChangeAuthor(db, notes, account));
+      }
+    }
+    return sort(comments);
+  }
+
+  private List<Comment> byCommentStatus(ResultSet<PatchLineComment> comments,
+      final PatchLineComment.Status status) {
+    return toComments(serverId, Lists.newArrayList(
+      Iterables.filter(comments, c -> c.getStatus() == status)));
+  }
+
+  public List<Comment> byPatchSet(ReviewDb db,
+      ChangeNotes notes, PatchSet.Id psId) throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(toComments(serverId,
+          db.patchComments().byPatchSet(psId).toList()));
+    }
+    List<Comment> comments = new ArrayList<>();
+    comments.addAll(publishedByPatchSet(db, notes, psId));
+
+    for (Ref ref : getDraftRefs(notes.getChangeId())) {
+      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
+      if (account != null) {
+        comments.addAll(draftByPatchSetAuthor(db, psId, account, notes));
+      }
+    }
+    return sort(comments);
+  }
+
+  public List<Comment> publishedByChangeFile(ReviewDb db, ChangeNotes notes,
+      Change.Id changeId, String file) throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(toComments(serverId,
+          db.patchComments().publishedByChangeFile(changeId, file).toList()));
+    }
+    return commentsOnFile(notes.load().getComments().values(), file);
+  }
+
+  public List<Comment> publishedByPatchSet(ReviewDb db,
+      ChangeNotes notes, PatchSet.Id psId) throws OrmException {
+    if (!migration.readChanges()) {
+      return removeCommentsOnAncestorOfCommitMessage(sort(toComments(serverId,
+          db.patchComments().publishedByPatchSet(psId).toList())));
+    }
+    return removeCommentsOnAncestorOfCommitMessage(
+        commentsOnPatchSet(notes.load().getComments().values(), psId));
+  }
+
+  public List<RobotComment> robotCommentsByPatchSet(ChangeNotes notes,
+      PatchSet.Id psId) throws OrmException {
+    if (!migration.readChanges()) {
+      return ImmutableList.of();
+    }
+    return commentsOnPatchSet(notes.load().getRobotComments().values(), psId);
+  }
+
+  /**
+   * For the commit message the A side in a diff view is always empty when a
+   * comparison against an ancestor is done, so there can't be any comments on
+   * this ancestor. However earlier we showed the auto-merge commit message on
+   * side A when for a merge commit a comparison against the auto-merge was
+   * done. From that time there may still be comments on the auto-merge commit
+   * message and those we want to filter out.
+   */
+  private List<Comment> removeCommentsOnAncestorOfCommitMessage(
+      List<Comment> list) {
+    return list.stream()
+        .filter(c -> c.side != 0 || !Patch.COMMIT_MSG.equals(c.key.filename))
+        .collect(toList());
+  }
+
+  public List<Comment> draftByPatchSetAuthor(ReviewDb db, PatchSet.Id psId,
+      Account.Id author, ChangeNotes notes) throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(toComments(serverId,
+          db.patchComments().draftByPatchSetAuthor(psId, author).toList()));
+    }
+    return commentsOnPatchSet(notes.load().getDraftComments(author).values(), psId);
+  }
+
+  public List<Comment> draftByChangeFileAuthor(ReviewDb db,
+      ChangeNotes notes, String file, Account.Id author)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(toComments(serverId,
+          db.patchComments()
+            .draftByChangeFileAuthor(notes.getChangeId(), file, author)
+            .toList()));
+    }
+    return commentsOnFile(notes.load().getDraftComments(author).values(), file);
+  }
+
+  public List<Comment> draftByChangeAuthor(ReviewDb db,
+      ChangeNotes notes, Account.Id author)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return StreamSupport
+          .stream(db.patchComments().draftByAuthor(author).spliterator(), false)
+          .filter(c -> c.getPatchSetId().getParentKey()
+              .equals(notes.getChangeId()))
+          .map(plc -> plc.asComment(serverId))
+          .sorted(COMMENT_ORDER)
+          .collect(toList());
+    }
+    List<Comment> comments = new ArrayList<>();
+    comments.addAll(notes.getDraftComments(author).values());
+    return sort(comments);
+  }
+
+  @Deprecated // To be used only by HasDraftByLegacyPredicate.
+  public List<Change.Id> changesWithDraftsByAuthor(ReviewDb db,
+      Account.Id author) throws OrmException {
+    if (!migration.readChanges()) {
+      return FluentIterable.from(db.patchComments().draftByAuthor(author))
+          .transform(plc -> plc.getPatchSetId().getParentKey()).toList();
+    }
+
+    List<Change.Id> changes = new ArrayList<>();
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      for (String refName : repo.getRefDatabase()
+          .getRefs(RefNames.REFS_DRAFT_COMMENTS).keySet()) {
+        Account.Id accountId = Account.Id.fromRefSuffix(refName);
+        Change.Id changeId = Change.Id.fromRefPart(refName);
+        if (accountId == null || changeId == null) {
+          continue;
+        }
+        changes.add(changeId);
+      }
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+    return changes;
+  }
+
+  public void putComments(ReviewDb db, ChangeUpdate update,
+      PatchLineComment.Status status, Iterable<Comment> comments)
+          throws OrmException {
+    for (Comment c : comments) {
+      update.putComment(status, c);
+    }
+    db.patchComments()
+        .upsert(toPatchLineComments(update.getId(), status, comments));
+  }
+
+  public void putRobotComments(ChangeUpdate update,
+      Iterable<RobotComment> comments) {
+    for (RobotComment c : comments) {
+      update.putRobotComment(c);
+    }
+  }
+
+  public void deleteComments(ReviewDb db, ChangeUpdate update,
+      Iterable<Comment> comments) throws OrmException {
+    for (Comment c : comments) {
+      update.deleteComment(c);
+    }
+    db.patchComments().delete(toPatchLineComments(update.getId(),
+        PatchLineComment.Status.DRAFT, comments));
+  }
+
+  public void deleteAllDraftsFromAllUsers(Change.Id changeId)
+      throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      for (Ref ref : getDraftRefs(repo, changeId)) {
+        bru.addCommand(new ReceiveCommand(
+            ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
+      }
+      bru.setRefLogMessage("Delete drafts from NoteDb", false);
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+      for (ReceiveCommand cmd : bru.getCommands()) {
+        if (cmd.getResult() != ReceiveCommand.Result.OK) {
+          throw new IOException(String.format(
+              "Failed to delete draft comment ref %s at %s: %s (%s)",
+              cmd.getRefName(), cmd.getOldId(), cmd.getResult(),
+              cmd.getMessage()));
+        }
+      }
+    }
+  }
+
+  private static List<Comment> commentsOnFile(Collection<Comment> allComments,
+      String file) {
+    List<Comment> result = new ArrayList<>(allComments.size());
+    for (Comment c : allComments) {
+      String currentFilename = c.key.filename;
+      if (currentFilename.equals(file)) {
+        result.add(c);
+      }
+    }
+    return sort(result);
+  }
+
+  private static <T extends Comment> List<T> commentsOnPatchSet(
+      Collection<T> allComments,
+      PatchSet.Id psId) {
+    List<T> result = new ArrayList<>(allComments.size());
+    for (T c : allComments) {
+      if (c.key.patchSetId == psId.get()) {
+        result.add(c);
+      }
+    }
+    return sort(result);
+  }
+
+  public static void setCommentRevId(Comment c,
+      PatchListCache cache, Change change, PatchSet ps) throws OrmException {
+    checkArgument(c.key.patchSetId == ps.getId().get(),
+        "cannot set RevId for patch set %s on comment %s", ps.getId(), c);
+    if (c.revId == null) {
+      try {
+        if (Side.fromShort(c.side) == Side.PARENT) {
+          if (c.side < 0) {
+            c.revId = ObjectId.toString(cache.getOldId(change, ps, -c.side));
+          } else {
+            c.revId = ObjectId.toString(cache.getOldId(change, ps, null));
+          }
+        } else {
+          c.revId = ps.getRevision().get();
+        }
+      } catch (PatchListNotAvailableException e) {
+        throw new OrmException(e);
+      }
+    }
+  }
+
+  public Collection<Ref> getDraftRefs(Change.Id changeId)
+      throws OrmException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return getDraftRefs(repo, changeId);
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId)
+      throws IOException {
+    return repo.getRefDatabase().getRefs(
+        RefNames.refsDraftCommentsPrefix(changeId)).values();
+  }
+
+  private static <T extends Comment> List<T> sort(List<T> comments) {
+    Collections.sort(comments, COMMENT_ORDER);
+    return comments;
+  }
+
+  public static Iterable<PatchLineComment> toPatchLineComments(
+      Change.Id changeId, PatchLineComment.Status status,
+      Iterable<Comment> comments) {
+    return FluentIterable.from(comments)
+        .transform(c -> PatchLineComment.from(changeId, status, c));
+  }
+
+  public static List<Comment> toComments(final String serverId,
+      Iterable<PatchLineComment> comments) {
+    return COMMENT_ORDER.sortedCopy(FluentIterable.from(comments)
+        .transform(plc -> plc.asComment(serverId)));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
index 34a2d02..668b344 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
@@ -20,6 +20,8 @@
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.inject.servlet.RequestScoped;
 
+import java.util.function.Consumer;
+
 /**
  * Information about the currently logged in user.
  * <p>
@@ -72,6 +74,16 @@
   }
 
   /**
+   * If the {@link #getRealUser()} has an account ID associated with it, call
+   * the given setter with that ID.
+   */
+  public void updateRealAccountId(Consumer<Account.Id> setter) {
+    if (getRealUser().isIdentifiedUser()) {
+      setter.accept(getRealUser().getAccountId());
+    }
+  }
+
+  /**
    * Get the set of groups the user is currently a member of.
    * <p>
    * The returned set may be a subset of the user's actual groups; if the user's
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/OptionUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/OptionUtil.java
index 24d10f7..c050a61 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/OptionUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/OptionUtil.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server;
 
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Function;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
 
@@ -24,16 +23,10 @@
   private static final Splitter COMMA_OR_SPACE =
       Splitter.on(CharMatcher.anyOf(", ")).omitEmptyStrings().trimResults();
 
-  private static final Function<String, String> TO_LOWER_CASE =
-      new Function<String, String>() {
-        @Override
-        public String apply(String input) {
-          return input.toLowerCase();
-        }
-      };
-
   public static Iterable<String> splitOptionValue(String value) {
-    return Iterables.transform(COMMA_OR_SPACE.split(value), TO_LOWER_CASE);
+    return Iterables.transform(
+        COMMA_OR_SPACE.split(value),
+        String::toLowerCase);
   }
 
   private OptionUtil() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
deleted file mode 100644
index 603f528..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
+++ /dev/null
@@ -1,405 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
-import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.DraftCommentNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Utility functions to manipulate PatchLineComments.
- * <p>
- * These methods either query for and update PatchLineComments in the NoteDb or
- * ReviewDb, depending on the state of the NotesMigration.
- */
-@Singleton
-public class PatchLineCommentsUtil {
-  public static final Ordering<PatchLineComment> PLC_ORDER =
-      new Ordering<PatchLineComment>() {
-    @Override
-    public int compare(PatchLineComment c1, PatchLineComment c2) {
-      String filename1 = c1.getKey().getParentKey().get();
-      String filename2 = c2.getKey().getParentKey().get();
-      return ComparisonChain.start()
-          .compare(filename1, filename2)
-          .compare(getCommentPsId(c1).get(), getCommentPsId(c2).get())
-          .compare(c1.getSide(), c2.getSide())
-          .compare(c1.getLine(), c2.getLine())
-          .compare(c1.getWrittenOn(), c2.getWrittenOn())
-          .result();
-    }
-  };
-
-  public static final Ordering<CommentInfo> COMMENT_INFO_ORDER =
-      new Ordering<CommentInfo>() {
-        @Override
-        public int compare(CommentInfo a, CommentInfo b) {
-          return ComparisonChain.start()
-              .compare(a.path, b.path, NULLS_FIRST)
-              .compare(a.patchSet, b.patchSet, NULLS_FIRST)
-              .compare(side(a), side(b))
-              .compare(a.line, b.line, NULLS_FIRST)
-              .compare(a.id, b.id)
-              .result();
-        }
-
-        private int side(CommentInfo c) {
-          return firstNonNull(c.side, Side.REVISION).ordinal();
-        }
-      };
-
-  public static PatchSet.Id getCommentPsId(PatchLineComment plc) {
-    return plc.getKey().getParentKey().getParentKey();
-  }
-
-  private static final Ordering<Comparable<?>> NULLS_FIRST =
-      Ordering.natural().nullsFirst();
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsers;
-  private final DraftCommentNotes.Factory draftFactory;
-  private final NotesMigration migration;
-
-  @Inject
-  PatchLineCommentsUtil(GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      DraftCommentNotes.Factory draftFactory,
-      NotesMigration migration) {
-    this.repoManager = repoManager;
-    this.allUsers = allUsers;
-    this.draftFactory = draftFactory;
-    this.migration = migration;
-  }
-
-  public Optional<PatchLineComment> get(ReviewDb db, ChangeNotes notes,
-      PatchLineComment.Key key) throws OrmException {
-    if (!migration.readChanges()) {
-      return Optional.fromNullable(db.patchComments().get(key));
-    }
-    for (PatchLineComment c : publishedByChange(db, notes)) {
-      if (key.equals(c.getKey())) {
-        return Optional.of(c);
-      }
-    }
-    for (PatchLineComment c : draftByChange(db, notes)) {
-      if (key.equals(c.getKey())) {
-        return Optional.of(c);
-      }
-    }
-    return Optional.absent();
-  }
-
-  public List<PatchLineComment> publishedByChange(ReviewDb db,
-      ChangeNotes notes) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(byCommentStatus(
-          db.patchComments().byChange(notes.getChangeId()), Status.PUBLISHED));
-    }
-
-    notes.load();
-    List<PatchLineComment> comments = new ArrayList<>();
-    comments.addAll(notes.getComments().values());
-    return sort(comments);
-  }
-
-  public List<PatchLineComment> draftByChange(ReviewDb db,
-      ChangeNotes notes) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(byCommentStatus(
-          db.patchComments().byChange(notes.getChangeId()), Status.DRAFT));
-    }
-
-    List<PatchLineComment> comments = new ArrayList<>();
-    for (Ref ref : getDraftRefs(notes.getChangeId())) {
-      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-      if (account != null) {
-        comments.addAll(draftByChangeAuthor(db, notes, account));
-      }
-    }
-    return sort(comments);
-  }
-
-  private static List<PatchLineComment> byCommentStatus(
-      ResultSet<PatchLineComment> comments,
-      final PatchLineComment.Status status) {
-    return Lists.newArrayList(
-      Iterables.filter(comments, new Predicate<PatchLineComment>() {
-        @Override
-        public boolean apply(PatchLineComment input) {
-          return (input.getStatus() == status);
-        }
-      })
-    );
-  }
-
-  public List<PatchLineComment> byPatchSet(ReviewDb db,
-      ChangeNotes notes, PatchSet.Id psId) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(db.patchComments().byPatchSet(psId).toList());
-    }
-    List<PatchLineComment> comments = new ArrayList<>();
-    comments.addAll(publishedByPatchSet(db, notes, psId));
-
-    for (Ref ref : getDraftRefs(notes.getChangeId())) {
-      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-      if (account != null) {
-        comments.addAll(draftByPatchSetAuthor(db, psId, account, notes));
-      }
-    }
-    return sort(comments);
-  }
-
-  public List<PatchLineComment> publishedByChangeFile(ReviewDb db,
-      ChangeNotes notes, Change.Id changeId, String file) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(
-          db.patchComments().publishedByChangeFile(changeId, file).toList());
-    }
-    return commentsOnFile(notes.load().getComments().values(), file);
-  }
-
-  public List<PatchLineComment> publishedByPatchSet(ReviewDb db,
-      ChangeNotes notes, PatchSet.Id psId) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(
-          db.patchComments().publishedByPatchSet(psId).toList());
-    }
-    return commentsOnPatchSet(notes.load().getComments().values(), psId);
-  }
-
-  public List<PatchLineComment> draftByPatchSetAuthor(ReviewDb db,
-      PatchSet.Id psId, Account.Id author, ChangeNotes notes)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(
-          db.patchComments().draftByPatchSetAuthor(psId, author).toList());
-    }
-    return commentsOnPatchSet(
-        notes.load().getDraftComments(author).values(), psId);
-  }
-
-  public List<PatchLineComment> draftByChangeFileAuthor(ReviewDb db,
-      ChangeNotes notes, String file, Account.Id author)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(
-          db.patchComments()
-            .draftByChangeFileAuthor(notes.getChangeId(), file, author)
-            .toList());
-    }
-    return commentsOnFile(
-        notes.load().getDraftComments(author).values(), file);
-  }
-
-  public List<PatchLineComment> draftByChangeAuthor(ReviewDb db,
-      ChangeNotes notes, Account.Id author)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      final Change.Id matchId = notes.getChangeId();
-      return FluentIterable
-          .from(db.patchComments().draftByAuthor(author))
-          .filter(new Predicate<PatchLineComment>() {
-            @Override
-            public boolean apply(PatchLineComment in) {
-              Change.Id changeId =
-                  in.getKey().getParentKey().getParentKey().getParentKey();
-              return changeId.equals(matchId);
-            }
-          }).toSortedList(PLC_ORDER);
-    }
-    List<PatchLineComment> comments = new ArrayList<>();
-    comments.addAll(notes.getDraftComments(author).values());
-    return sort(comments);
-  }
-
-  @Deprecated // To be used only by HasDraftByLegacyPredicate.
-  public List<PatchLineComment> draftByAuthor(ReviewDb db,
-      Account.Id author) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(db.patchComments().draftByAuthor(author).toList());
-    }
-
-    List<PatchLineComment> comments = new ArrayList<>();
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      for (String refName : repo.getRefDatabase()
-          .getRefs(RefNames.REFS_DRAFT_COMMENTS).keySet()) {
-        Account.Id accountId = Account.Id.fromRefSuffix(refName);
-        Change.Id changeId = Change.Id.fromRefPart(refName);
-        if (accountId == null || changeId == null) {
-          continue;
-        }
-        // Avoid loading notes for all affected changes just to be able to auto-
-        // rebuild. This is only used in a corner case in the search codepath,
-        // so returning slightly stale values is ok.
-        DraftCommentNotes notes =
-            draftFactory.createWithAutoRebuildingDisabled(changeId, author);
-        comments.addAll(notes.load().getComments().values());
-      }
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-    return sort(comments);
-  }
-
-  public void putComments(ReviewDb db, ChangeUpdate update,
-      Iterable<PatchLineComment> comments) throws OrmException {
-    for (PatchLineComment c : comments) {
-      update.putComment(c);
-    }
-    db.patchComments().upsert(comments);
-  }
-
-  public void deleteComments(ReviewDb db, ChangeUpdate update,
-      Iterable<PatchLineComment> comments) throws OrmException {
-    for (PatchLineComment c : comments) {
-      update.deleteComment(c);
-    }
-    db.patchComments().delete(comments);
-  }
-
-  public void deleteAllDraftsFromAllUsers(Change.Id changeId)
-      throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-      for (Ref ref : getDraftRefs(repo, changeId)) {
-        bru.addCommand(new ReceiveCommand(
-            ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
-      }
-      bru.setRefLogMessage("Delete drafts from NoteDb", false);
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-      for (ReceiveCommand cmd : bru.getCommands()) {
-        if (cmd.getResult() != ReceiveCommand.Result.OK) {
-          throw new IOException(String.format(
-              "Failed to delete draft comment ref %s at %s: %s (%s)",
-              cmd.getRefName(), cmd.getOldId(), cmd.getResult(),
-              cmd.getMessage()));
-        }
-      }
-    }
-  }
-
-  private static List<PatchLineComment> commentsOnFile(
-      Collection<PatchLineComment> allComments,
-      String file) {
-    List<PatchLineComment> result = new ArrayList<>(allComments.size());
-    for (PatchLineComment c : allComments) {
-      String currentFilename = c.getKey().getParentKey().getFileName();
-      if (currentFilename.equals(file)) {
-        result.add(c);
-      }
-    }
-    return sort(result);
-  }
-
-  private static List<PatchLineComment> commentsOnPatchSet(
-      Collection<PatchLineComment> allComments,
-      PatchSet.Id psId) {
-    List<PatchLineComment> result = new ArrayList<>(allComments.size());
-    for (PatchLineComment c : allComments) {
-      if (getCommentPsId(c).equals(psId)) {
-        result.add(c);
-      }
-    }
-    return sort(result);
-  }
-
-  public static RevId setCommentRevId(PatchLineComment c,
-      PatchListCache cache, Change change, PatchSet ps) throws OrmException {
-    checkArgument(c.getPatchSetId().equals(ps.getId()),
-        "cannot set RevId for patch set %s on comment %s", ps.getId(), c);
-    if (c.getRevId() == null) {
-      try {
-        if (Side.fromShort(c.getSide()) == Side.PARENT) {
-          if (c.getSide() < 0) {
-            c.setRevId(new RevId(ObjectId.toString(
-                cache.getOldId(change, ps, -c.getSide()))));
-          } else {
-            c.setRevId(new RevId(ObjectId.toString(
-                cache.getOldId(change, ps, null))));
-          }
-        } else {
-          c.setRevId(ps.getRevision());
-        }
-      } catch (PatchListNotAvailableException e) {
-        throw new OrmException(e);
-      }
-    }
-    return c.getRevId();
-  }
-
-  public Collection<Ref> getDraftRefs(Change.Id changeId)
-      throws OrmException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getDraftRefs(repo, changeId);
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId)
-      throws IOException {
-    return repo.getRefDatabase().getRefs(
-        RefNames.refsDraftCommentsPrefix(changeId)).values();
-  }
-
-  private static List<PatchLineComment> sort(List<PatchLineComment> comments) {
-    Collections.sort(comments, PLC_ORDER);
-    return comments;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
new file mode 100644
index 0000000..0ee10f0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
@@ -0,0 +1,284 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ReviewerSuggestion;
+import com.google.gerrit.server.change.SuggestReviewers;
+import com.google.gerrit.server.change.SuggestedReviewer;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.apache.commons.lang.mutable.MutableDouble;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public class ReviewerRecommender {
+  private static final Logger log =
+      LoggerFactory.getLogger(ReviewersUtil.class);
+  private static final double BASE_REVIEWER_WEIGHT = 10;
+  private static final double BASE_OWNER_WEIGHT = 1;
+  private static final double BASE_COMMENT_WEIGHT = 0.5;
+  private static final double[] WEIGHTS = new double[] {
+      BASE_REVIEWER_WEIGHT, BASE_OWNER_WEIGHT, BASE_COMMENT_WEIGHT,};
+  private static final long PLUGIN_QUERY_TIMEOUT = 500; //ms
+
+  private final ChangeQueryBuilder changeQueryBuilder;
+  private final Config config;
+  private final DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap;
+  private final InternalChangeQuery internalChangeQuery;
+  private final WorkQueue workQueue;
+  private final Provider<ReviewDb> dbProvider;
+  private final ApprovalsUtil approvalsUtil;
+
+  @Inject
+  ReviewerRecommender(ChangeQueryBuilder changeQueryBuilder,
+      DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap,
+      InternalChangeQuery internalChangeQuery,
+      WorkQueue workQueue,
+      Provider<ReviewDb> dbProvider,
+      ApprovalsUtil approvalsUtil,
+      @GerritServerConfig Config config) {
+    Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
+    fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+    this.changeQueryBuilder = changeQueryBuilder;
+    this.config = config;
+    this.internalChangeQuery = internalChangeQuery;
+    this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
+    this.workQueue = workQueue;
+    this.dbProvider = dbProvider;
+    this.approvalsUtil = approvalsUtil;
+  }
+
+  public List<Account.Id> suggestReviewers(
+      ChangeNotes changeNotes,
+      SuggestReviewers suggestReviewers, ProjectControl projectControl,
+      List<Account.Id> candidateList)
+      throws OrmException {
+    String query = suggestReviewers.getQuery();
+    double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
+
+    Map<Account.Id, MutableDouble> reviewerScores;
+    if (Strings.isNullOrEmpty(query)) {
+      reviewerScores = baseRankingForEmptyQuery(baseWeight);
+    } else {
+      reviewerScores = baseRankingForCandidateList(
+          candidateList, projectControl, baseWeight);
+    }
+
+    // Send the query along with a candidate list to all plugins and merge the
+    // results. Plugins don't necessarily need to use the candidates list, they
+    // can also return non-candidate account ids.
+    List<Callable<Set<SuggestedReviewer>>> tasks =
+        new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
+    List<Double> weights =
+        new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
+
+    for (DynamicMap.Entry<ReviewerSuggestion> plugin :
+        reviewerSuggestionPluginMap) {
+      tasks.add(() -> plugin.getProvider().get()
+          .suggestReviewers(projectControl.getProject().getNameKey(),
+              changeNotes.getChangeId(), query, reviewerScores.keySet()));
+      String pluginWeight = config.getString("addReviewer",
+          plugin.getPluginName() + "-" + plugin.getExportName(), "weight");
+      if (Strings.isNullOrEmpty(pluginWeight)) {
+        pluginWeight = "1";
+      }
+      try {
+        weights.add(Double.parseDouble(pluginWeight));
+      } catch (NumberFormatException e) {
+        log.error("Exception while parsing weight for " +
+            plugin.getPluginName() + "-" + plugin.getExportName(), e);
+        weights.add(1d);
+      }
+    }
+
+    try {
+      List<Future<Set<SuggestedReviewer>>> futures = workQueue
+          .getDefaultQueue()
+          .invokeAll(tasks, PLUGIN_QUERY_TIMEOUT, TimeUnit.MILLISECONDS);
+      Iterator<Double> weightIterator = weights.iterator();
+      for (Future<Set<SuggestedReviewer>> f : futures) {
+        double weight = weightIterator.next();
+        for (SuggestedReviewer s : f.get()) {
+          if (reviewerScores.containsKey(s.account)) {
+            reviewerScores.get(s.account).add(s.score * weight);
+          } else {
+            reviewerScores.put(s.account, new MutableDouble(s.score * weight));
+          }
+        }
+      }
+    } catch (ExecutionException | InterruptedException e) {
+      log.error("Exception while suggesting reviewers", e);
+      return ImmutableList.of();
+    }
+
+    if (changeNotes != null) {
+      // Remove change owner
+      reviewerScores.remove(changeNotes.getChange().getOwner());
+
+      // Remove existing reviewers
+      reviewerScores.keySet().removeAll(
+          approvalsUtil.getReviewers(dbProvider.get(), changeNotes)
+              .byState(REVIEWER));
+    }
+
+    // Sort results
+    Stream<Entry<Account.Id, MutableDouble>> sorted =
+        reviewerScores.entrySet().stream()
+            .sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
+    List<Account.Id> sortedSuggestions = sorted
+        .map(Map.Entry::getKey)
+        .collect(Collectors.toList());
+    return sortedSuggestions;
+  }
+
+  private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(
+      double baseWeight) throws OrmException{
+    // Get the user's last 50 changes, check approvals
+    try {
+      List<ChangeData> result = internalChangeQuery
+          .setLimit(50)
+          .setRequestedFields(ImmutableSet.of(ChangeField.REVIEWER.getName()))
+          .query(changeQueryBuilder.owner("self"));
+      Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
+      for (ChangeData cd : result) {
+        for (PatchSetApproval approval : cd.currentApprovals()) {
+          Account.Id id = approval.getAccountId();
+          if (suggestions.containsKey(id)) {
+            suggestions.get(id).add(baseWeight);
+          } else {
+            suggestions.put(id, new MutableDouble(baseWeight));
+          }
+        }
+      }
+      return suggestions;
+    } catch (QueryParseException e) {
+      // Unhandled, because owner:self will never provoke a QueryParseException
+      log.error("Exception while suggesting reviewers", e);
+      return ImmutableMap.of();
+    }
+  }
+
+  private Map<Account.Id, MutableDouble> baseRankingForCandidateList(
+      List<Account.Id> candidates,
+      ProjectControl projectControl,
+      double baseWeight) throws OrmException {
+    // Get each reviewer's activity based on number of applied labels
+    // (weighted 10d), number of comments (weighted 0.5d) and number of owned
+    // changes (weighted 1d).
+    Map<Account.Id, MutableDouble> reviewers = new LinkedHashMap<>();
+    if (candidates.size() == 0) {
+      return reviewers;
+    }
+    List<Predicate<ChangeData>> predicates = new ArrayList<>();
+    for (Account.Id id : candidates) {
+      try {
+        Predicate<ChangeData> projectQuery =
+            changeQueryBuilder.project(projectControl.getProject().getName());
+
+        // Get all labels for this project and create a compound OR query to
+        // fetch all changes where users have applied one of these labels
+        List<LabelType> labelTypes =
+            projectControl.getLabelTypes().getLabelTypes();
+        List<Predicate<ChangeData>> labelPredicates =
+            new ArrayList<>(labelTypes.size());
+        for (LabelType type : labelTypes) {
+          labelPredicates
+              .add(changeQueryBuilder.label(type.getName() + ",user=" + id));
+        }
+        Predicate<ChangeData> reviewerQuery =
+            Predicate.and(projectQuery, Predicate.or(labelPredicates));
+
+        Predicate<ChangeData> ownerQuery = Predicate.and(projectQuery,
+            changeQueryBuilder.owner(id.toString()));
+        Predicate<ChangeData> commentedByQuery = Predicate.and(projectQuery,
+            changeQueryBuilder.commentby(id.toString()));
+
+        predicates.add(reviewerQuery);
+        predicates.add(ownerQuery);
+        predicates.add(commentedByQuery);
+        reviewers.put(id, new MutableDouble());
+      } catch (QueryParseException e) {
+        // Unhandled: If an exception is thrown, we won't increase the
+        // candidates's score
+        log.error("Exception while suggesting reviewers", e);
+      }
+    }
+
+    List<List<ChangeData>> result = internalChangeQuery
+        .setLimit(100 * predicates.size())
+        .setRequestedFields(ImmutableSet.of())
+        .query(predicates);
+
+    Iterator<List<ChangeData>> queryResultIterator = result.iterator();
+    Iterator<Account.Id> reviewersIterator = reviewers.keySet().iterator();
+
+    int i = 0;
+    Account.Id currentId = null;
+    while (queryResultIterator.hasNext()) {
+      List<ChangeData> currentResult = queryResultIterator.next();
+      if (i % WEIGHTS.length == 0) {
+        currentId = reviewersIterator.next();
+      }
+
+      reviewers.get(currentId).add(WEIGHTS[i % WEIGHTS.length] *
+          baseWeight * currentResult.size());
+      i++;
+    }
+    return reviewers;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
index e1f786b..1781f1a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
@@ -14,20 +14,15 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.base.Function;
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupBaseInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
@@ -44,6 +39,7 @@
 import com.google.gerrit.server.change.SuggestReviewers;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.query.QueryParseException;
@@ -56,164 +52,158 @@
 
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
+import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 
 public class ReviewersUtil {
   private static final String MAX_SUFFIX = "\u9fa5";
-  private static final Ordering<SuggestedReviewerInfo> ORDERING =
-      Ordering.natural().onResultOf(new Function<SuggestedReviewerInfo, String>() {
-        @Nullable
-        @Override
-        public String apply(@Nullable SuggestedReviewerInfo suggestedReviewerInfo) {
-          if (suggestedReviewerInfo == null) {
-            return null;
-          }
-          return suggestedReviewerInfo.account != null
-              ? MoreObjects.firstNonNull(suggestedReviewerInfo.account.email,
-              Strings.nullToEmpty(suggestedReviewerInfo.account.name))
-              : Strings.nullToEmpty(suggestedReviewerInfo.group.name);
-        }
-      });
-  private final AccountLoader accountLoader;
+  // Generate a candidate list at 3x the size of what the user wants to see to
+  // give the ranking algorithm a good set of candidates it can work with
+  private static final int CANDIDATE_LIST_MULTIPLIER = 3;
+
   private final AccountCache accountCache;
-  private final AccountIndexCollection indexes;
-  private final AccountQueryBuilder queryBuilder;
-  private final AccountQueryProcessor queryProcessor;
   private final AccountControl accountControl;
-  private final Provider<ReviewDb> dbProvider;
+  private final AccountIndexCollection accountIndexes;
+  private final AccountLoader accountLoader;
+  private final AccountQueryBuilder accountQueryBuilder;
+  private final AccountQueryProcessor accountQueryProcessor;
   private final GroupBackend groupBackend;
   private final GroupMembers.Factory groupMembersFactory;
   private final Provider<CurrentUser> currentUser;
+  private final Provider<ReviewDb> dbProvider;
+  private final ReviewerRecommender reviewerRecommender;
 
   @Inject
-  ReviewersUtil(AccountLoader.Factory accountLoaderFactory,
-      AccountCache accountCache,
-      AccountIndexCollection indexes,
-      AccountQueryBuilder queryBuilder,
-      AccountQueryProcessor queryProcessor,
+  ReviewersUtil(AccountCache accountCache,
       AccountControl.Factory accountControlFactory,
-      Provider<ReviewDb> dbProvider,
+      AccountIndexCollection accountIndexes,
+      AccountLoader.Factory accountLoaderFactory,
+      AccountQueryBuilder accountQueryBuilder,
+      AccountQueryProcessor accountQueryProcessor,
       GroupBackend groupBackend,
       GroupMembers.Factory groupMembersFactory,
-      Provider<CurrentUser> currentUser) {
+      Provider<CurrentUser> currentUser,
+      Provider<ReviewDb> dbProvider,
+      ReviewerRecommender reviewerRecommender) {
     Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
     fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
-    this.accountLoader = accountLoaderFactory.create(fillOptions);
     this.accountCache = accountCache;
-    this.indexes = indexes;
-    this.queryBuilder = queryBuilder;
-    this.queryProcessor = queryProcessor;
     this.accountControl = accountControlFactory.get();
+    this.accountIndexes = accountIndexes;
+    this.accountLoader = accountLoaderFactory.create(fillOptions);
+    this.accountQueryBuilder = accountQueryBuilder;
+    this.accountQueryProcessor = accountQueryProcessor;
+    this.currentUser = currentUser;
     this.dbProvider = dbProvider;
     this.groupBackend = groupBackend;
     this.groupMembersFactory = groupMembersFactory;
-    this.currentUser = currentUser;
+    this.reviewerRecommender = reviewerRecommender;
   }
 
   public interface VisibilityControl {
     boolean isVisibleTo(Account.Id account) throws OrmException;
   }
 
-  public List<SuggestedReviewerInfo> suggestReviewers(
+  public List<SuggestedReviewerInfo> suggestReviewers(ChangeNotes changeNotes,
       SuggestReviewers suggestReviewers, ProjectControl projectControl,
-      VisibilityControl visibilityControl)
-      throws IOException, OrmException, BadRequestException {
+      VisibilityControl visibilityControl, boolean excludeGroups)
+      throws IOException, OrmException {
     String query = suggestReviewers.getQuery();
-    boolean suggestAccounts = suggestReviewers.getSuggestAccounts();
-    int suggestFrom = suggestReviewers.getSuggestFrom();
     int limit = suggestReviewers.getLimit();
 
-    if (Strings.isNullOrEmpty(query)) {
-      throw new BadRequestException("missing query field");
-    }
-
-    if (!suggestAccounts || query.length() < suggestFrom) {
+    if (!suggestReviewers.getSuggestAccounts()) {
       return Collections.emptyList();
     }
 
-    Collection<AccountInfo> suggestedAccounts =
-        suggestAccounts(suggestReviewers, visibilityControl);
-
-    List<SuggestedReviewerInfo> reviewer = new ArrayList<>();
-    for (AccountInfo a : suggestedAccounts) {
-      SuggestedReviewerInfo info = new SuggestedReviewerInfo();
-      info.account = a;
-      info.count = 1;
-      reviewer.add(info);
+    List<Account.Id> candidateList = new ArrayList<>();
+    if (!Strings.isNullOrEmpty(query)) {
+      candidateList = suggestAccounts(suggestReviewers, visibilityControl);
     }
 
-    for (GroupReference g : suggestAccountGroup(suggestReviewers, projectControl)) {
-      GroupAsReviewer result = suggestGroupAsReviewer(
-          suggestReviewers, projectControl.getProject(), g, visibilityControl);
-      if (result.allowed || result.allowedWithConfirmation) {
-        GroupBaseInfo info = new GroupBaseInfo();
-        info.id = Url.encode(g.getUUID().get());
-        info.name = g.getName();
-        SuggestedReviewerInfo suggestedReviewerInfo = new SuggestedReviewerInfo();
-        suggestedReviewerInfo.group = info;
-        suggestedReviewerInfo.count = result.size;
-        if (result.allowedWithConfirmation) {
-          suggestedReviewerInfo.confirm = true;
+    List<Account.Id> sortedRecommendations = reviewerRecommender
+        .suggestReviewers(changeNotes, suggestReviewers, projectControl,
+            candidateList);
+
+    // Populate AccountInfo
+    List<SuggestedReviewerInfo> reviewer = new ArrayList<>();
+    for (Account.Id id : sortedRecommendations) {
+      AccountInfo account = accountLoader.get(id);
+      if (account != null) {
+        SuggestedReviewerInfo info = new SuggestedReviewerInfo();
+        info.account = account;
+        info.count = 1;
+        reviewer.add(info);
+      }
+    }
+    accountLoader.fill();
+
+    if (!excludeGroups && !Strings.isNullOrEmpty(query)) {
+      for (GroupReference g : suggestAccountGroup(suggestReviewers, projectControl)) {
+        GroupAsReviewer result = suggestGroupAsReviewer(
+            suggestReviewers, projectControl.getProject(), g, visibilityControl);
+        if (result.allowed || result.allowedWithConfirmation) {
+          GroupBaseInfo info = new GroupBaseInfo();
+          info.id = Url.encode(g.getUUID().get());
+          info.name = g.getName();
+          SuggestedReviewerInfo suggestedReviewerInfo = new SuggestedReviewerInfo();
+          suggestedReviewerInfo.group = info;
+          suggestedReviewerInfo.count = result.size;
+          if (result.allowedWithConfirmation) {
+            suggestedReviewerInfo.confirm = true;
+          }
+          // Always add groups at the end as individual accounts are usually
+          // more important
+          reviewer.add(suggestedReviewerInfo);
         }
-        reviewer.add(suggestedReviewerInfo);
       }
     }
 
-    reviewer = ORDERING.immutableSortedCopy(reviewer);
     if (reviewer.size() <= limit) {
       return reviewer;
     }
     return reviewer.subList(0, limit);
   }
 
-  private Collection<AccountInfo> suggestAccounts(SuggestReviewers suggestReviewers,
+  private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers,
       VisibilityControl visibilityControl)
       throws OrmException {
-    AccountIndex searchIndex = indexes.getSearchIndex();
+    AccountIndex searchIndex = accountIndexes.getSearchIndex();
     if (searchIndex != null) {
       return suggestAccountsFromIndex(suggestReviewers);
     }
     return suggestAccountsFromDb(suggestReviewers, visibilityControl);
   }
 
-  private Collection<AccountInfo> suggestAccountsFromIndex(
+  private List<Account.Id> suggestAccountsFromIndex(
       SuggestReviewers suggestReviewers) throws OrmException {
     try {
-      Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
-      QueryResult<AccountState> result = queryProcessor
-          .setLimit(suggestReviewers.getLimit())
-          .query(queryBuilder.defaultQuery(suggestReviewers.getQuery()));
+      Set<Account.Id> matches = new HashSet<>();
+      QueryResult<AccountState> result = accountQueryProcessor
+          .setLimit(suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER)
+          .query(accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
       for (AccountState accountState : result.entities()) {
         Account.Id id = accountState.getAccount().getId();
-        matches.put(id, accountLoader.get(id));
+        matches.add(id);
       }
-
-      accountLoader.fill();
-
-      return matches.values();
+      return new ArrayList<>(matches);
     } catch (QueryParseException e) {
       return ImmutableList.of();
     }
   }
 
-  private Collection<AccountInfo> suggestAccountsFromDb(
+  private List<Account.Id> suggestAccountsFromDb(
       SuggestReviewers suggestReviewers, VisibilityControl visibilityControl)
           throws OrmException {
     String query = suggestReviewers.getQuery();
-    int limit = suggestReviewers.getLimit();
+    int limit = suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER;
 
     String a = query;
     String b = a + MAX_SUFFIX;
 
-    Map<Account.Id, AccountInfo> r = new LinkedHashMap<>();
-    Map<Account.Id, String> queryEmail = new HashMap<>();
+    Set<Account.Id> r = new HashSet<>();
 
     for (Account p : dbProvider.get().accounts()
         .suggestByFullName(a, b, limit)) {
@@ -234,36 +224,26 @@
     if (r.size() < limit) {
       for (AccountExternalId e : dbProvider.get().accountExternalIds()
           .suggestByEmailAddress(a, b, limit - r.size())) {
-        if (!r.containsKey(e.getAccountId())) {
+        if (!r.contains(e.getAccountId())) {
           Account p = accountCache.get(e.getAccountId()).getAccount();
           if (p.isActive()) {
-            if (addSuggestion(r, p.getId(), visibilityControl)) {
-              queryEmail.put(e.getAccountId(), e.getEmailAddress());
-            }
+            addSuggestion(r, p.getId(), visibilityControl);
           }
         }
       }
     }
-
-    accountLoader.fill();
-    for (Map.Entry<Account.Id, String> p : queryEmail.entrySet()) {
-      AccountInfo info = r.get(p.getKey());
-      if (info != null) {
-        info.email = p.getValue();
-      }
-    }
-    return new ArrayList<>(r.values());
+    return new ArrayList<>(r);
   }
 
-  private boolean addSuggestion(Map<Account.Id, AccountInfo> map,
+  private boolean addSuggestion(Set<Account.Id> map,
       Account.Id account, VisibilityControl visibilityControl)
       throws OrmException {
-    if (!map.containsKey(account)
+    if (!map.contains(account)
         // Can the suggestion see the change?
         && visibilityControl.isVisibleTo(account)
         // Can the current user see the account?
         && accountControl.canSee(account)) {
-      map.put(account, accountLoader.get(account));
+      map.add(account);
       return true;
     }
     return false;
@@ -282,7 +262,8 @@
     int size;
   }
 
-  private GroupAsReviewer suggestGroupAsReviewer(SuggestReviewers suggestReviewers,
+  private GroupAsReviewer suggestGroupAsReviewer(
+      SuggestReviewers suggestReviewers,
       Project project, GroupReference group,
       VisibilityControl visibilityControl) throws OrmException, IOException {
     GroupAsReviewer result = new GroupAsReviewer();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
index 5a89afa..8f25e43 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -15,14 +15,12 @@
 package com.google.gerrit.server;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
-import com.google.common.base.Predicate;
 import com.google.common.base.Splitter;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
@@ -249,29 +247,11 @@
   public Set<Account.Id> byChange(final Change.Id changeId,
       final String label) throws OrmException {
     try (final Repository repo = repoManager.openRepository(allUsers)) {
-      return FluentIterable
-          .from(getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId)))
-          .transform(new Function<String, Account.Id>() {
-            @Override
-            public Account.Id apply(String refPart) {
-              return Account.Id.parse(refPart);
-            }
-          })
-          .filter(new Predicate<Account.Id>() {
-            @Override
-            public boolean apply(Account.Id accountId) {
-              try {
-                return readLabels(repo,
-                    RefNames.refsStarredChanges(changeId, accountId))
-                        .contains(label);
-              } catch (IOException e) {
-                log.error(String.format(
-                    "Cannot query stars by account %d on change %d",
-                    accountId.get(), changeId.get()), e);
-                return false;
-              }
-            }
-          }).toSet();
+      return getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId))
+          .stream()
+          .map(Account.Id::parse)
+          .filter(accountId -> hasStar(repo, changeId, accountId, label))
+          .collect(toSet());
     } catch (IOException e) {
       throw new OrmException(
           String.format("Get accounts that starred change %d failed",
@@ -283,36 +263,12 @@
   // To be used only for IsStarredByLegacyPredicate.
   public Set<Change.Id> byAccount(final Account.Id accountId,
       final String label) throws OrmException {
-    try (final Repository repo = repoManager.openRepository(allUsers)) {
-      return FluentIterable
-          .from(getRefNames(repo, RefNames.REFS_STARRED_CHANGES))
-          .filter(new Predicate<String>() {
-            @Override
-            public boolean apply(String refPart) {
-              return refPart.endsWith("/" + accountId.get());
-            }
-          })
-          .transform(new Function<String, Change.Id>() {
-            @Override
-            public Change.Id apply(String refPart) {
-              return Change.Id.fromRefPart(refPart);
-            }
-          })
-          .filter(new Predicate<Change.Id>() {
-            @Override
-            public boolean apply(Change.Id changeId) {
-              try {
-                return readLabels(repo,
-                    RefNames.refsStarredChanges(changeId, accountId))
-                        .contains(label);
-              } catch (IOException e) {
-                log.error(String.format(
-                    "Cannot query stars by account %d on change %d",
-                    accountId.get(), changeId.get()), e);
-                return false;
-              }
-            }
-          }).toSet();
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return getRefNames(repo, RefNames.REFS_STARRED_CHANGES).stream()
+          .filter(refPart -> refPart.endsWith("/" + accountId.get()))
+          .map(Change.Id::fromRefPart)
+          .filter(changeId -> hasStar(repo, changeId, accountId, label))
+          .collect(toSet());
     } catch (IOException e) {
       throw new OrmException(
           String.format("Get changes that were starred by %d failed",
@@ -320,6 +276,20 @@
     }
   }
 
+  private boolean hasStar(Repository repo, Change.Id changeId,
+      Account.Id accountId, String label) {
+    try {
+      return readLabels(repo,
+          RefNames.refsStarredChanges(changeId, accountId))
+              .contains(label);
+    } catch (IOException e) {
+      log.error(String.format(
+          "Cannot query stars by account %d on change %d",
+          accountId.get(), changeId.get()), e);
+      return false;
+    }
+  }
+
   public ImmutableMultimap<Account.Id, String> byChangeFromIndex(
       Change.Id changeId) throws OrmException, NoSuchChangeException {
     Set<String> fields = ImmutableSet.of(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
index 761f2a3..6dccbc2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
@@ -39,37 +39,31 @@
 @Singleton
 public class WebLinks {
   private static final Logger log = LoggerFactory.getLogger(WebLinks.class);
+
   private static final Predicate<WebLinkInfo> INVALID_WEBLINK =
-      new Predicate<WebLinkInfo>() {
-
-        @Override
-        public boolean apply(WebLinkInfo link) {
-          if (link == null) {
-            return false;
-          } else if (Strings.isNullOrEmpty(link.name)
-              || Strings.isNullOrEmpty(link.url)) {
-            log.warn(String.format("%s is missing name and/or url",
-                link.getClass().getName()));
-            return false;
-          }
-          return true;
+      link -> {
+        if (link == null) {
+          return false;
+        } else if (Strings.isNullOrEmpty(link.name)
+            || Strings.isNullOrEmpty(link.url)) {
+          log.warn(String.format("%s is missing name and/or url",
+              link.getClass().getName()));
+          return false;
         }
+        return true;
       };
-  private static final Predicate<WebLinkInfoCommon> INVALID_WEBLINK_COMMON =
-      new Predicate<WebLinkInfoCommon>() {
 
-        @Override
-        public boolean apply(WebLinkInfoCommon link) {
-          if (link == null) {
-            return false;
-          } else if (Strings.isNullOrEmpty(link.name)
-              || Strings.isNullOrEmpty(link.url)) {
-            log.warn(String.format("%s is missing name and/or url", link
-                .getClass().getName()));
-            return false;
-          }
-          return true;
+  private static final Predicate<WebLinkInfoCommon> INVALID_WEBLINK_COMMON =
+      link -> {
+        if (link == null) {
+          return false;
+        } else if (Strings.isNullOrEmpty(link.name)
+            || Strings.isNullOrEmpty(link.url)) {
+          log.warn(String.format("%s is missing name and/or url", link
+              .getClass().getName()));
+          return false;
         }
+        return true;
       };
 
   private final DynamicSet<PatchSetWebLink> patchSetLinks;
@@ -85,8 +79,7 @@
       DynamicSet<FileHistoryWebLink> fileLogLinks,
       DynamicSet<DiffWebLink> diffLinks,
       DynamicSet<ProjectWebLink> projectLinks,
-      DynamicSet<BranchWebLink> branchLinks
-      ) {
+      DynamicSet<BranchWebLink> branchLinks) {
     this.patchSetLinks = patchSetLinks;
     this.fileLinks = fileLinks;
     this.fileHistoryLinks = fileLogLinks;
@@ -101,15 +94,11 @@
    * @param commit SHA1 of commit.
    * @return Links for patch sets.
    */
-  public FluentIterable<WebLinkInfo> getPatchSetLinks(final Project.NameKey project,
-      final String commit) {
-    return filterLinks(patchSetLinks, new Function<WebLink, WebLinkInfo>() {
-
-      @Override
-      public WebLinkInfo apply(WebLink webLink) {
-        return ((PatchSetWebLink)webLink).getPatchSetWebLink(project.get(), commit);
-      }
-    });
+  public FluentIterable<WebLinkInfo> getPatchSetLinks(Project.NameKey project,
+      String commit) {
+    return filterLinks(
+        patchSetLinks,
+        webLink -> webLink.getPatchSetWebLink(project.get(), commit));
   }
 
   /**
@@ -119,15 +108,11 @@
    * @param file File name.
    * @return Links for files.
    */
-  public FluentIterable<WebLinkInfo> getFileLinks(final String project, final String revision,
-      final String file) {
-    return filterLinks(fileLinks, new Function<WebLink, WebLinkInfo>() {
-
-      @Override
-      public WebLinkInfo apply(WebLink webLink) {
-        return ((FileWebLink)webLink).getFileWebLink(project, revision, file);
-      }
-    });
+  public FluentIterable<WebLinkInfo> getFileLinks(String project,
+      String revision, String file) {
+    return filterLinks(
+        fileLinks,
+        webLink -> webLink.getFileWebLink(project, revision, file));
   }
 
   /**
@@ -137,39 +122,31 @@
    * @param file File name.
    * @return Links for file history
    */
-  public FluentIterable<WebLinkInfo> getFileHistoryLinks(final String project,
-      final String revision, final String file) {
-    return filterLinks(fileHistoryLinks, new Function<WebLink, WebLinkInfo>() {
-
-      @Override
-      public WebLinkInfo apply(WebLink webLink) {
-        return ((FileHistoryWebLink) webLink).getFileHistoryWebLink(project,
-            revision, file);
-      }
-    });
+  public FluentIterable<WebLinkInfo> getFileHistoryLinks(String project,
+      String revision, String file) {
+    return filterLinks(
+        fileHistoryLinks,
+        webLink -> webLink.getFileHistoryWebLink(project, revision, file));
   }
 
   public FluentIterable<WebLinkInfoCommon> getFileHistoryLinksCommon(
-      final String project, final String revision, final String file) {
+      String project, String revision, String file) {
     return FluentIterable
         .from(fileHistoryLinks)
-        .transform(new Function<WebLink, WebLinkInfoCommon>() {
-          @Override
-          public WebLinkInfoCommon apply(WebLink webLink) {
-            WebLinkInfo info =
-                ((FileHistoryWebLink) webLink).getFileHistoryWebLink(project,
-                    revision, file);
-            if (info == null) {
-              return null;
-            }
-            WebLinkInfoCommon commonInfo = new WebLinkInfoCommon();
-            commonInfo.name = info.name;
-            commonInfo.imageUrl = info.imageUrl;
-            commonInfo.url = info.url;
-            commonInfo.target = info.target;
-            return commonInfo;
-          }
-        })
+        .transform(
+            webLink -> {
+              WebLinkInfo info =
+                  webLink.getFileHistoryWebLink(project, revision, file);
+              if (info == null) {
+                return null;
+              }
+              WebLinkInfoCommon commonInfo = new WebLinkInfoCommon();
+              commonInfo.name = info.name;
+              commonInfo.imageUrl = info.imageUrl;
+              commonInfo.url = info.url;
+              commonInfo.target = info.target;
+              return commonInfo;
+            })
         .filter(INVALID_WEBLINK_COMMON);
   }
 
@@ -190,14 +167,10 @@
       final int patchSetIdB, final String revisionB, final String fileB) {
    return FluentIterable
        .from(diffLinks)
-       .transform(new Function<WebLink, DiffWebLinkInfo>() {
-         @Override
-         public DiffWebLinkInfo apply(WebLink webLink) {
-            return ((DiffWebLink) webLink).getDiffLink(project, changeId,
+       .transform(webLink ->
+            webLink.getDiffLink(project, changeId,
                 patchSetIdA, revisionA, fileA,
-                patchSetIdB, revisionB, fileB);
-          }
-       })
+                patchSetIdB, revisionB, fileB))
        .filter(INVALID_WEBLINK);
  }
 
@@ -207,13 +180,9 @@
    * @return Links for projects.
    */
   public FluentIterable<WebLinkInfo> getProjectLinks(final String project) {
-    return filterLinks(projectLinks, new Function<WebLink, WebLinkInfo>() {
-
-      @Override
-      public WebLinkInfo apply(WebLink webLink) {
-        return ((ProjectWebLink)webLink).getProjectWeblink(project);
-      }
-    });
+    return filterLinks(
+        projectLinks,
+        webLink -> webLink.getProjectWeblink(project));
   }
 
   /**
@@ -223,17 +192,13 @@
    * @return Links for branches.
    */
   public FluentIterable<WebLinkInfo> getBranchLinks(final String project, final String branch) {
-    return filterLinks(branchLinks, new Function<WebLink, WebLinkInfo>() {
-
-      @Override
-      public WebLinkInfo apply(WebLink webLink) {
-        return ((BranchWebLink)webLink).getBranchWebLink(project, branch);
-      }
-    });
+    return filterLinks(
+        branchLinks,
+        webLink -> webLink.getBranchWebLink(project, branch));
   }
 
-  private FluentIterable<WebLinkInfo> filterLinks(DynamicSet<? extends WebLink> links,
-      Function<WebLink, WebLinkInfo> transformer) {
+  private <T extends WebLink> FluentIterable<WebLinkInfo> filterLinks(DynamicSet<T> links,
+      Function<T, WebLinkInfo> transformer) {
     return FluentIterable
         .from(links)
         .transform(transformer)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
index 30420e0..8b4453f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
@@ -16,11 +16,10 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.EmailSender;
+import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.Inject;
 
 import java.util.Collection;
@@ -37,11 +36,11 @@
   }
 
   @Override
-  public Set<FieldName> getEditableFields() {
-    Set<Account.FieldName> fields = new  HashSet<>();
-    for (Account.FieldName n : Account.FieldName.values()) {
+  public Set<AccountFieldName> getEditableFields() {
+    Set<AccountFieldName> fields = new  HashSet<>();
+    for (AccountFieldName n : AccountFieldName.values()) {
       if (allowsEdit(n)) {
-        if (n == Account.FieldName.REGISTER_NEW_EMAIL) {
+        if (n == AccountFieldName.REGISTER_NEW_EMAIL) {
           if (emailSender != null && emailSender.isEnabled()) {
             fields.add(n);
           }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 149931d..846b44b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Optional;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableSet;
@@ -52,6 +51,7 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 
@@ -252,17 +252,13 @@
       if (accountIndexes.getSearchIndex() != null) {
         AccountState accountState =
             accountQueryProvider.get().oneByExternalId(key.get());
-        return accountState != null
-            ? Optional.of(accountState.getAccount().getId())
-            : Optional.<Account.Id>absent();
+        return Optional.ofNullable(accountState)
+            .map(s -> s.getAccount().getId());
       }
 
       try (ReviewDb db = schema.open()) {
-        AccountExternalId id = db.accountExternalIds().get(key);
-        if (id != null) {
-          return Optional.of(id.getAccountId());
-        }
-        return Optional.absent();
+        return Optional.ofNullable(db.accountExternalIds().get(key))
+            .map(AccountExternalId::getAccountId);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
index c5b0699..db2a98f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Predicate;
-import com.google.common.collect.Sets;
+import static java.util.stream.Collectors.toSet;
+
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -28,7 +28,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
-import java.util.HashSet;
 import java.util.Set;
 
 /** Access control management for one account's access to other accounts. */
@@ -186,14 +185,9 @@
   }
 
   private Set<AccountGroup.UUID> groupsOf(IdentifiedUser user) {
-    return new HashSet<>(Sets.filter(
-      user.getEffectiveGroups().getKnownGroups(),
-      new Predicate<AccountGroup.UUID>() {
-        @Override
-        public boolean apply(AccountGroup.UUID in) {
-          return !SystemGroupBackend.isSystemGroup(in);
-        }
-      }));
+    return user.getEffectiveGroups().getKnownGroups().stream()
+        .filter(a -> !SystemGroupBackend.isSystemGroup(a))
+        .collect(toSet());
   }
 
   private abstract static class OtherUser {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
index 63d2fc6..9ac69ba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -52,10 +52,6 @@
 
   @SuppressWarnings("serial")
   public static class DirectoryException extends Exception {
-    public DirectoryException(String message) {
-      super(message);
-    }
-
     public DirectoryException(String message, Throwable why) {
       super(message, why);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountJson.java
new file mode 100644
index 0000000..7193564
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountJson.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.reviewdb.client.Account;
+
+public class AccountJson {
+
+  public static AccountInfo toAccountInfo(Account account) {
+    if (account == null || account.getId() == null) {
+      return null;
+    }
+    AccountInfo accountInfo = new AccountInfo(account.getId().get());
+    accountInfo.email = account.getPreferredEmail();
+    accountInfo.name = account.getFullName();
+    accountInfo.username = account.getUserName();
+    return accountInfo;
+  }
+
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java
index f84d399..89e9419 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java
@@ -94,7 +94,7 @@
       directory.fillAccountInfo(
           Iterables.concat(created.values(), provided), options);
     } catch (DirectoryException e) {
-      Throwables.propagateIfPossible(e.getCause(), OrmException.class);
+      Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
       throw new OrmException(e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
index e32795f..8baef83 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -201,14 +202,14 @@
       db.accountExternalIds().update(Collections.singleton(extId));
     }
 
-    if (!realm.allowsEdit(Account.FieldName.FULL_NAME)
+    if (!realm.allowsEdit(AccountFieldName.FULL_NAME)
         && !Strings.isNullOrEmpty(who.getDisplayName())
         && !eq(user.getAccount().getFullName(), who.getDisplayName())) {
       toUpdate = load(toUpdate, user.getAccountId(), db);
       toUpdate.setFullName(who.getDisplayName());
     }
 
-    if (!realm.allowsEdit(Account.FieldName.USER_NAME)
+    if (!realm.allowsEdit(AccountFieldName.USER_NAME)
         && who.getUserName() != null
         && !eq(user.getUserName(), who.getUserName())) {
       log.warn(String.format("Not changing already set username %s to %s",
@@ -341,7 +342,7 @@
     } else {
       log.error(errorMessage);
     }
-    if (!realm.allowsEdit(Account.FieldName.USER_NAME)) {
+    if (!realm.allowsEdit(AccountFieldName.USER_NAME)) {
       // setting the given user name has failed, but the realm does not
       // allow the user to manually set a user name,
       // this means we would end with an account without user name
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
index 5a18269..b400eb7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
+import static java.util.stream.Collectors.toSet;
+
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -191,14 +191,9 @@
 
       // At this point we have no clue. Just perform a whole bunch of suggestions
       // and pray we come up with a reasonable result list.
-      return FluentIterable
-          .from(accountQueryProvider.get().byDefault(nameOrEmail))
-          .transform(new Function<AccountState, Account.Id>() {
-            @Override
-            public Account.Id apply(AccountState accountState) {
-              return accountState.getAccount().getId();
-            }
-          }).toSet();
+      return accountQueryProvider.get().byDefault(nameOrEmail).stream()
+          .map(a -> a.getAccount().getId())
+          .collect(toSet());
     }
 
     List<Account> m = db.accounts().byFullName(nameOrEmail).toList();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
index 05a7179..ed99266 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
@@ -36,12 +36,7 @@
 
 public class AccountState {
   public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
-      new Function<AccountState, Account.Id>() {
-        @Override
-        public Account.Id apply(AccountState in) {
-          return in.getAccount().getId();
-        }
-      };
+      a -> a.getAccount().getId();
 
   private final Account account;
   private final Set<AccountGroup.UUID> internalGroups;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
index 04ebc87..c7ce1b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -90,15 +91,7 @@
    */
   public IdentifiedUser parse(String id) throws AuthException,
       UnprocessableEntityException, OrmException {
-    IdentifiedUser user = parseId(id);
-    if (user == null) {
-      throw new UnprocessableEntityException(String.format(
-          "Account Not Found: %s", id));
-    } else if (!accountControlFactory.get().canSee(user.getAccount())) {
-      throw new UnprocessableEntityException(String.format(
-          "Account Not Found: %s", id));
-    }
-    return user;
+    return parseOnBehalfOf(null, id);
   }
 
   /**
@@ -115,6 +108,29 @@
    * @throws OrmException
    */
   public IdentifiedUser parseId(String id) throws AuthException, OrmException {
+    return parseIdOnBehalfOf(null, id);
+  }
+
+  /**
+   * Like {@link #parse(String)}, but also sets the {@link
+   * CurrentUser#getRealUser()} on the result.
+   */
+  public IdentifiedUser parseOnBehalfOf(@Nullable CurrentUser caller,
+      String id)
+      throws AuthException, UnprocessableEntityException, OrmException {
+    IdentifiedUser user = parseIdOnBehalfOf(caller, id);
+    if (user == null) {
+      throw new UnprocessableEntityException(String.format(
+          "Account Not Found: %s", id));
+    } else if (!accountControlFactory.get().canSee(user.getAccount())) {
+      throw new UnprocessableEntityException(String.format(
+          "Account Not Found: %s", id));
+    }
+    return user;
+  }
+
+  private IdentifiedUser parseIdOnBehalfOf(@Nullable CurrentUser caller,
+      String id) throws AuthException, OrmException {
     if (id.equals("self")) {
       CurrentUser user = self.get();
       if (user.isIdentifiedUser()) {
@@ -130,7 +146,8 @@
     if (match == null) {
       return null;
     }
-    return userFactory.create(match.getId());
+    CurrentUser realUser = caller != null ? caller.getRealUser() : null;
+    return userFactory.runAs(null, match.getId(), realUser);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
index 216672c..8cc392a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AddSshKey.Input;
-import com.google.gerrit.server.mail.AddKeySender;
+import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java
index 0e8c051..45dbe60 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Optional;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 
 public class AuthorizedKeys {
   public static final String FILE_NAME = "authorized_keys";
@@ -47,7 +47,7 @@
         key.setInvalid();
         keys.add(Optional.of(key));
       } else if (line.startsWith(DELETED_KEY_COMMENT)) {
-        keys.add(Optional.<AccountSshKey> absent());
+        keys.add(Optional.empty());
         seq++;
       } else if (line.startsWith("#")) {
         continue;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
index e348e73..d86d27c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
@@ -14,15 +14,14 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Function;
+import static com.google.common.base.Predicates.not;
+
 import com.google.common.base.Predicate;
-import com.google.common.base.Predicates;
-import com.google.common.collect.Iterables;
+import com.google.common.collect.FluentIterable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.git.QueueProvider;
@@ -32,6 +31,7 @@
 import com.google.inject.assistedinject.Assisted;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -98,7 +98,7 @@
     if (canEmailReviewers == null) {
       canEmailReviewers =
           matchAny(capabilities.emailReviewers, ALLOWED_RULE)
-          || !matchAny(capabilities.emailReviewers, Predicates.not(ALLOWED_RULE));
+          || !matchAny(capabilities.emailReviewers, not(ALLOWED_RULE));
 
     }
     return canEmailReviewers;
@@ -279,23 +279,16 @@
     return mine;
   }
 
-  private static final Predicate<PermissionRule> ALLOWED_RULE = new Predicate<PermissionRule>() {
-    @Override
-    public boolean apply(PermissionRule rule) {
-      return rule.getAction() == Action.ALLOW;
-    }
-  };
+  private static final Predicate<PermissionRule> ALLOWED_RULE =
+      r -> r.getAction() == Action.ALLOW;
 
-  private boolean matchAny(Iterable<PermissionRule> rules, Predicate<PermissionRule> predicate) {
-    Iterable<AccountGroup.UUID> ids = Iterables.transform(
-        Iterables.filter(rules, predicate),
-        new Function<PermissionRule, AccountGroup.UUID>() {
-          @Override
-          public AccountGroup.UUID apply(PermissionRule rule) {
-            return rule.getGroup().getUUID();
-          }
-        });
-    return user.getEffectiveGroups().containsAnyOf(ids);
+  private boolean matchAny(Collection<PermissionRule> rules,
+      Predicate<PermissionRule> predicate) {
+    return user.getEffectiveGroups()
+        .containsAnyOf(
+            FluentIterable.from(rules)
+                .filter(predicate)
+                .transform(r -> r.getGroup().getUUID()));
   }
 
   private static boolean match(GroupMembership groups,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
index 8d121c2..905d2f0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
@@ -38,7 +38,7 @@
 import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.mail.OutgoingEmailValidator;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
index 713154c..ecee4b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.gerrit.extensions.client.AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT;
+
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -23,14 +26,12 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GetEmails.EmailInfo;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.mail.OutgoingEmailValidator;
-import com.google.gerrit.server.mail.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -50,11 +51,11 @@
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
-  private final AuthConfig authConfig;
   private final AccountManager accountManager;
   private final RegisterNewEmailSender.Factory registerNewEmailFactory;
   private final PutPreferred putPreferred;
   private final String email;
+  private final boolean isDevMode;
 
   @Inject
   CreateEmail(Provider<CurrentUser> self,
@@ -66,11 +67,11 @@
       @Assisted String email) {
     this.self = self;
     this.realm = realm;
-    this.authConfig = authConfig;
     this.accountManager = accountManager;
     this.registerNewEmailFactory = registerNewEmailFactory;
     this.putPreferred = putPreferred;
     this.email = email;
+    this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
   }
 
   @Override
@@ -96,7 +97,7 @@
       throw new AuthException("not allowed to use no_confirmation");
     }
 
-    if (!realm.allowsEdit(FieldName.REGISTER_NEW_EMAIL)) {
+    if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
       throw new MethodNotAllowedException("realm does not allow adding emails");
     }
 
@@ -113,8 +114,10 @@
 
     EmailInfo info = new EmailInfo();
     info.email = email;
-    if (input.noConfirmation
-        || authConfig.getAuthType() == AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT) {
+    if (input.noConfirmation || isDevMode) {
+      if (isDevMode) {
+        log.warn("skipping email validation in developer mode");
+      }
       try {
         accountManager.link(user.getAccountId(),
             AuthRequest.forEmail(email));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
index eb3c9a0..57af333 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -39,7 +40,7 @@
   }
 
   @Override
-  public boolean allowsEdit(final Account.FieldName field) {
+  public boolean allowsEdit(final AccountFieldName field) {
     if (authConfig.getAuthType() == AuthType.HTTP) {
       switch (field) {
         case USER_NAME:
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
index f6c48af..94c099e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
@@ -16,11 +16,14 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.DeleteActive.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -38,22 +41,28 @@
 
   private final Provider<ReviewDb> dbProvider;
   private final AccountCache byIdCache;
+  private final Provider<IdentifiedUser> self;
 
   @Inject
-  DeleteActive(Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
+  DeleteActive(Provider<ReviewDb> dbProvider, AccountCache byIdCache,
+      Provider<IdentifiedUser> self) {
     this.dbProvider = dbProvider;
     this.byIdCache = byIdCache;
+    this.self = self;
   }
 
   @Override
   public Response<?> apply(AccountResource rsrc, Input input)
-      throws ResourceNotFoundException, OrmException, IOException {
+      throws RestApiException, OrmException, IOException {
     Account a = dbProvider.get().accounts().get(rsrc.getUser().getAccountId());
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
     }
     if (!a.isActive()) {
-      throw new ResourceNotFoundException();
+      throw new ResourceConflictException("account not active");
+    }
+    if (self.get() == rsrc.getUser()) {
+      throw new ResourceConflictException("cannot deactivate own account");
     }
     a.setActive(false);
     dbProvider.get().accounts().update(Collections.singleton(a));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
index 76f63b7..1f073ae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
@@ -67,7 +67,7 @@
   public Response<?> apply(IdentifiedUser user, String email)
       throws ResourceNotFoundException, ResourceConflictException,
       MethodNotAllowedException, OrmException, IOException {
-    if (!realm.allowsEdit(FieldName.REGISTER_NEW_EMAIL)) {
+    if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
       throw new MethodNotAllowedException("realm does not allow deleting emails");
     }
     AccountExternalId.Key key = new AccountExternalId.Key(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
index e2fbc3c..0e9bc2e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Function;
-import com.google.common.collect.Lists;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -105,13 +105,10 @@
 
   private void deleteFromGit(Account.Id accountId, List<ProjectWatchInfo> input)
       throws IOException, ConfigInvalidException {
-    watchConfig.deleteProjectWatches(accountId, Lists.transform(input,
-        new Function<ProjectWatchInfo, ProjectWatchKey>() {
-          @Override
-          public ProjectWatchKey apply(ProjectWatchInfo info) {
-            return ProjectWatchKey.create(new Project.NameKey(info.project),
-                info.filter);
-          }
-        }));
+    watchConfig.deleteProjectWatches(
+        accountId,
+        input.stream().map(w -> ProjectWatchKey.create(
+                new Project.NameKey(w.project), w.filter))
+            .collect(toList()));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java
index d3b938f..a53f64e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
 
 /** Fake implementation of {@link Realm} that does not communicate. */
 public class FakeRealm extends AbstractRealm {
   @Override
-  public boolean allowsEdit(FieldName field) {
+  public boolean allowsEdit(AccountFieldName field) {
     return false;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetActive.java
index 10b6df9..9864b45 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetActive.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.inject.Singleton;
@@ -22,9 +21,9 @@
 @Singleton
 public class GetActive implements RestReadView<AccountResource> {
   @Override
-  public Object apply(AccountResource rsrc) {
+  public Response<String> apply(AccountResource rsrc) {
     if (rsrc.getUser().getAccount().isActive()) {
-      return BinaryResult.create("ok\n");
+      return Response.ok("ok");
     }
     return Response.none();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java
index 9e1201a..46d6f11 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AgreementJson;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
@@ -46,14 +47,17 @@
 
   private final Provider<CurrentUser> self;
   private final ProjectCache projectCache;
+  private final AgreementJson agreementJson;
   private final boolean agreementsEnabled;
 
   @Inject
   GetAgreements(Provider<CurrentUser> self,
       ProjectCache projectCache,
+      AgreementJson agreementJson,
       @GerritServerConfig Config config) {
     this.self = self;
     this.projectCache = projectCache;
+    this.agreementJson = agreementJson;
     this.agreementsEnabled =
         config.getBoolean("auth", "contributorAgreements", false);
   }
@@ -85,17 +89,13 @@
             groupIds.add(rule.getGroup().getUUID());
           } else {
             log.warn("group \"" + rule.getGroup().getName() + "\" does not " +
-                " exist, referenced in CLA \"" + ca.getName() + "\"");
+                "exist, referenced in CLA \"" + ca.getName() + "\"");
           }
         }
       }
 
       if (user.getEffectiveGroups().containsAnyOf(groupIds)) {
-        AgreementInfo info = new AgreementInfo();
-        info.name = ca.getName();
-        info.description = ca.getDescription();
-        info.url = ca.getAgreementUrl();
-        results.add(info);
+        results.add(agreementJson.format(ca));
       }
     }
     return results;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java
index 81c860e..e47ceb3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java
@@ -47,7 +47,7 @@
       directory.fillAccountInfo(Collections.singleton(info),
           EnumSet.allOf(FillOptions.class));
     } catch (DirectoryException e) {
-      Throwables.propagateIfPossible(e.getCause(), OrmException.class);
+      Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
       throw new OrmException(e);
     }
     return info;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
index bf1a3af..df125e0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Function;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.common.SshKeyInfo;
@@ -60,13 +59,9 @@
 
   public List<SshKeyInfo> apply(IdentifiedUser user)
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    return Lists.transform(authorizedKeys.getKeys(user.getAccountId()),
-        new Function<AccountSshKey, SshKeyInfo>() {
-          @Override
-          public SshKeyInfo apply(AccountSshKey key) {
-            return newSshKeyInfo(key);
-          }
-        });
+    return Lists.transform(
+        authorizedKeys.getKeys(user.getAccountId()),
+        GetSshKeys::newSshKeyInfo);
   }
 
   public static SshKeyInfo newSshKeyInfo(AccountSshKey sshKey) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
index c7a2241..3d966d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 
@@ -31,8 +32,8 @@
   @Nullable
   AccountGroup get(AccountGroup.UUID uuid);
 
-  /** @return sorted iteration of groups. */
-  Iterable<AccountGroup> all();
+  /** @return sorted list of groups. */
+  ImmutableList<AccountGroup> all();
 
   /** Notify the cache that a new group was constructed. */
   void onCreateGroup(AccountGroup.NameKey newGroupName);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
index e5e2f99..4f5cc2b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Optional;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -33,8 +33,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 
 /** Tracks group objects in memory for efficient access. */
@@ -130,7 +130,7 @@
       return null;
     }
     try {
-      return byName.get(name.get()).orNull();
+      return byName.get(name.get()).orElse(null);
     } catch (ExecutionException e) {
       log.warn(String.format("Cannot lookup group %s by name", name.get()), e);
       return null;
@@ -143,7 +143,7 @@
       return null;
     }
     try {
-      return byUUID.get(uuid.get()).orNull();
+      return byUUID.get(uuid.get()).orElse(null);
     } catch (ExecutionException e) {
       log.warn(String.format("Cannot lookup group %s by name", uuid.get()), e);
       return null;
@@ -151,12 +151,12 @@
   }
 
   @Override
-  public Iterable<AccountGroup> all() {
+  public ImmutableList<AccountGroup> all() {
     try (ReviewDb db = schema.open()) {
-      return Collections.unmodifiableList(db.accountGroups().all().toList());
+      return ImmutableList.copyOf(db.accountGroups().all());
     } catch (OrmException e) {
       log.warn("Cannot list internal groups", e);
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
   }
 
@@ -183,7 +183,7 @@
     public Optional<AccountGroup> load(final AccountGroup.Id key)
         throws Exception {
       try (ReviewDb db = schema.open()) {
-        return Optional.fromNullable(db.accountGroups().get(key));
+        return Optional.ofNullable(db.accountGroups().get(key));
       }
     }
   }
@@ -203,9 +203,9 @@
         AccountGroup.NameKey key = new AccountGroup.NameKey(name);
         AccountGroupName r = db.accountGroupNames().get(key);
         if (r != null) {
-          return Optional.fromNullable(db.accountGroups().get(r.getId()));
+          return Optional.ofNullable(db.accountGroups().get(r.getId()));
         }
-        return Optional.absent();
+        return Optional.empty();
       }
     }
   }
@@ -228,7 +228,7 @@
         if (r.size() == 1) {
           return Optional.of(r.get(0));
         } else if (r.size() == 0) {
-          return Optional.absent();
+          return Optional.empty();
         } else {
           throw new OrmDuplicateKeyException("Duplicate group UUID " + uuid);
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
index c47d6f8..2028654 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Function;
-import com.google.common.base.Predicate;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.data.GroupReference;
@@ -34,14 +32,6 @@
 /** Implementation of GroupBackend for the internal group system. */
 @Singleton
 public class InternalGroupBackend implements GroupBackend {
-  private static final Function<AccountGroup, GroupReference> ACT_GROUP_TO_GROUP_REF =
-      new Function<AccountGroup, GroupReference>() {
-        @Override
-        public GroupReference apply(AccountGroup group) {
-          return GroupReference.forGroup(group);
-        }
-      };
-
   private final GroupControl.Factory groupControlFactory;
   private final GroupCache groupCache;
   private final IncludingGroupMembership.Factory groupMembershipFactory;
@@ -77,16 +67,13 @@
   @Override
   public Collection<GroupReference> suggest(final String name,
       final ProjectControl project) {
-    Iterable<AccountGroup> filtered = Iterables.filter(groupCache.all(),
-        new Predicate<AccountGroup>() {
-          @Override
-          public boolean apply(AccountGroup group) {
+    return groupCache.all().stream()
+        .filter(group ->
             // startsWithIgnoreCase && isVisible
-            return group.getName().regionMatches(true, 0, name, 0, name.length())
-                && groupControlFactory.controlFor(group).isVisible();
-          }
-        });
-    return Lists.newArrayList(Iterables.transform(filtered, ACT_GROUP_TO_GROUP_REF));
+            group.getName().regionMatches(true, 0, name, 0, name.length())
+                && groupControlFactory.controlFor(group).isVisible())
+        .map(GroupReference::forGroup)
+        .collect(toList());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
index 9197011..239b954 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.inject.Singleton;
 
@@ -23,7 +25,7 @@
 public class PutAccount
     implements RestModifyView<AccountResource, AccountInput> {
   @Override
-  public Object apply(AccountResource resource, AccountInput input)
+  public Response<AccountInfo> apply(AccountResource resource, AccountInput input)
       throws ResourceConflictException {
     throw new ResourceConflictException("account exists");
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
index 2fdf666..b8b902f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -68,7 +69,7 @@
   }
 
   @Override
-  public Object apply(AccountResource resource, AgreementInput input)
+  public Response<String> apply(AccountResource resource, AgreementInput input)
       throws IOException, OrmException, RestApiException {
     if (!agreementsEnabled) {
       throw new MethodNotAllowedException("contributor agreements disabled");
@@ -103,7 +104,7 @@
     addMembers.addMembers(group.getId(), ImmutableList.of(account.getId()));
     agreementSignup.fire(account, agreementName);
 
-    return agreementName;
+    return Response.ok(agreementName);
   }
 
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
index e0b69a6..74c07e8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -22,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -74,7 +74,7 @@
       input = new Input();
     }
 
-    if (!realm.allowsEdit(FieldName.FULL_NAME)) {
+    if (!realm.allowsEdit(AccountFieldName.FULL_NAME)) {
       throw new MethodNotAllowedException("realm does not allow editing name");
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
index e9dc393..29168ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.PutUsername.Input;
@@ -64,7 +64,7 @@
       throw new AuthException("not allowed to set username");
     }
 
-    if (!realm.allowsEdit(Account.FieldName.USER_NAME)) {
+    if (!realm.allowsEdit(AccountFieldName.USER_NAME)) {
       throw new MethodNotAllowedException("realm does not allow editing username");
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
index 85fde4e..627f529 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 
@@ -21,10 +22,10 @@
 
 public interface Realm {
   /** Can the end-user modify this field of their own account? */
-  boolean allowsEdit(Account.FieldName field);
+  boolean allowsEdit(AccountFieldName field);
 
   /** Returns the account fields that the end-user can modify. */
-  Set<Account.FieldName> getEditableFields();
+  Set<AccountFieldName> getEditableFields();
 
   AuthRequest authenticate(AuthRequest who) throws AccountException;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index bb744ce..41ae498 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.errors.InvalidSshKeyException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -46,6 +45,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 
 /**
  * 'authorized_keys' file in the refs/users/CD/ABCD branches of the All-Users
@@ -192,7 +192,8 @@
   /** Returns all SSH keys. */
   private List<AccountSshKey> getKeys() {
     checkLoaded();
-    return Lists.newArrayList(Optional.presentInstances(keys));
+    return keys.stream().filter(Optional::isPresent).map(Optional::get)
+        .collect(toList());
   }
 
   /**
@@ -205,8 +206,7 @@
    */
   private AccountSshKey getKey(int seq) {
     checkLoaded();
-    Optional<AccountSshKey> key = keys.get(seq - 1);
-    return key.orNull();
+    return keys.get(seq - 1).orElse(null);
   }
 
   /**
@@ -246,7 +246,7 @@
   private boolean deleteKey(int seq) {
     checkLoaded();
     if (seq <= keys.size() && keys.get(seq - 1).isPresent()) {
-      keys.set(seq - 1, Optional.<AccountSshKey> absent());
+      keys.set(seq - 1, Optional.empty());
       return true;
     }
     return false;
@@ -278,15 +278,10 @@
    * @param newKeys the new public SSH keys
    */
   public void setKeys(Collection<AccountSshKey> newKeys) {
-    Ordering<AccountSshKey> o =
-        Ordering.natural().onResultOf(new Function<AccountSshKey, Integer>() {
-          @Override
-          public Integer apply(AccountSshKey sshKey) {
-            return sshKey.getKey().get();
-          }
-        });
-    keys = new ArrayList<>(Collections.nCopies(o.max(newKeys).getKey().get(),
-        Optional.<AccountSshKey> absent()));
+    Ordering<AccountSshKey> o = Ordering.from(comparing(k -> k.getKey().get()));
+    keys = new ArrayList<>(
+        Collections.nCopies(o.max(newKeys).getKey().get(),
+        Optional.empty()));
     for (AccountSshKey key : newKeys) {
       keys.set(key.getKey().get() - 1, Optional.of(key));
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
index a3cd0c9..8f96e92 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
@@ -21,7 +21,6 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Enums;
 import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
@@ -319,9 +318,9 @@
       if (i + 1 < notifyValue.length() - 2) {
         for (String nt : Splitter.on(',').trimResults().splitToList(
             notifyValue.substring(i + 1, notifyValue.length() - 1))) {
-          Optional<NotifyType> notifyType =
-              Enums.getIfPresent(NotifyType.class, nt);
-          if (!notifyType.isPresent()) {
+          NotifyType notifyType =
+              Enums.getIfPresent(NotifyType.class, nt).orNull();
+          if (notifyType == null) {
             validationErrorSink.error(new ValidationError(WATCH_CONFIG,
                 String.format(
                     "Invalid notify type %s in project watch "
@@ -329,7 +328,7 @@
                     nt, accountId.get(), project, notifyValue)));
             continue;
           }
-          notifyTypes.add(notifyType.get());
+          notifyTypes.add(notifyType);
         }
       }
       return create(filter, notifyTypes);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 2af9f1d..3533fe8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.accounts;
 
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
@@ -31,6 +33,7 @@
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.GpgException;
@@ -38,8 +41,10 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AddSshKey;
 import com.google.gerrit.server.account.CreateEmail;
+import com.google.gerrit.server.account.DeleteActive;
 import com.google.gerrit.server.account.DeleteSshKey;
 import com.google.gerrit.server.account.DeleteWatchedProjects;
+import com.google.gerrit.server.account.GetActive;
 import com.google.gerrit.server.account.GetAgreements;
 import com.google.gerrit.server.account.GetAvatar;
 import com.google.gerrit.server.account.GetDiffPreferences;
@@ -48,6 +53,7 @@
 import com.google.gerrit.server.account.GetSshKeys;
 import com.google.gerrit.server.account.GetWatchedProjects;
 import com.google.gerrit.server.account.PostWatchedProjects;
+import com.google.gerrit.server.account.PutActive;
 import com.google.gerrit.server.account.PutAgreement;
 import com.google.gerrit.server.account.SetDiffPreferences;
 import com.google.gerrit.server.account.SetEditPreferences;
@@ -99,6 +105,9 @@
   private final SshKeys sshKeys;
   private final GetAgreements getAgreements;
   private final PutAgreement putAgreement;
+  private final GetActive getActive;
+  private final PutActive putActive;
+  private final DeleteActive deleteActive;
 
   @Inject
   AccountApiImpl(AccountLoader.Factory ailf,
@@ -126,6 +135,9 @@
       SshKeys sshKeys,
       GetAgreements getAgreements,
       PutAgreement putAgreement,
+      GetActive getActive,
+      PutActive putActive,
+      DeleteActive deleteActive,
       @Assisted AccountResource account) {
     this.account = account;
     this.accountLoaderFactory = ailf;
@@ -153,6 +165,9 @@
     this.gpgApiAdapter = gpgApiAdapter;
     this.getAgreements = getAgreements;
     this.putAgreement = putAgreement;
+    this.getActive = getActive;
+    this.putActive = putActive;
+    this.deleteActive = deleteActive;
   }
 
   @Override
@@ -169,6 +184,25 @@
   }
 
   @Override
+  public boolean getActive() throws RestApiException {
+    Response<String> result = getActive.apply(account);
+    return result.statusCode() == SC_OK && result.value().equals("ok");
+  }
+
+  @Override
+  public void setActive(boolean active) throws RestApiException {
+    try {
+      if (active) {
+        putActive.apply(account, new PutActive.Input());
+      } else {
+        deleteActive.apply(account, new DeleteActive.Input());
+      }
+    } catch (OrmException | IOException e) {
+      throw new RestApiException("Cannot set active", e);
+    }
+  }
+
+  @Override
   public String getAvatarUrl(int size) throws RestApiException {
     getAvatar.setSize(size);
     return getAvatar.apply(account).location();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 9bfb342..a265160 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.api.changes.FixInput;
@@ -28,9 +29,11 @@
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
@@ -40,8 +43,12 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.Check;
-import com.google.gerrit.server.change.DeleteDraftChange;
+import com.google.gerrit.server.change.CreateMergePatchSet;
+import com.google.gerrit.server.change.DeleteAssignee;
+import com.google.gerrit.server.change.DeleteChange;
+import com.google.gerrit.server.change.GetAssignee;
 import com.google.gerrit.server.change.GetHashtags;
+import com.google.gerrit.server.change.GetPastAssignees;
 import com.google.gerrit.server.change.GetTopic;
 import com.google.gerrit.server.change.Index;
 import com.google.gerrit.server.change.ListChangeComments;
@@ -50,6 +57,7 @@
 import com.google.gerrit.server.change.PostHashtags;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.change.PublishDraftPatchSet;
+import com.google.gerrit.server.change.PutAssignee;
 import com.google.gerrit.server.change.PutTopic;
 import com.google.gerrit.server.change.Restore;
 import com.google.gerrit.server.change.Revert;
@@ -58,9 +66,11 @@
 import com.google.gerrit.server.change.SubmittedTogether;
 import com.google.gerrit.server.change.SuggestChangeReviewers;
 import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
 import java.io.IOException;
@@ -84,16 +94,21 @@
   private final Abandon abandon;
   private final Revert revert;
   private final Restore restore;
-  private final SubmittedTogether submittedTogether;
+  private final CreateMergePatchSet updateByMerge;
+  private final Provider<SubmittedTogether> submittedTogether;
   private final PublishDraftPatchSet.CurrentRevision
     publishDraftChange;
-  private final DeleteDraftChange deleteDraftChange;
+  private final DeleteChange deleteChange;
   private final GetTopic getTopic;
   private final PutTopic putTopic;
   private final PostReviewers postReviewers;
   private final ChangeJson.Factory changeJson;
   private final PostHashtags postHashtags;
   private final GetHashtags getHashtags;
+  private final PutAssignee putAssignee;
+  private final GetAssignee getAssignee;
+  private final GetPastAssignees getPastAssignees;
+  private final DeleteAssignee deleteAssignee;
   private final ListChangeComments listComments;
   private final ListChangeDrafts listDrafts;
   private final Check check;
@@ -111,15 +126,20 @@
       Abandon abandon,
       Revert revert,
       Restore restore,
-      SubmittedTogether submittedTogether,
+      CreateMergePatchSet updateByMerge,
+      Provider<SubmittedTogether> submittedTogether,
       PublishDraftPatchSet.CurrentRevision publishDraftChange,
-      DeleteDraftChange deleteDraftChange,
+      DeleteChange deleteChange,
       GetTopic getTopic,
       PutTopic putTopic,
       PostReviewers postReviewers,
       ChangeJson.Factory changeJson,
       PostHashtags postHashtags,
       GetHashtags getHashtags,
+      PutAssignee putAssignee,
+      GetAssignee getAssignee,
+      GetPastAssignees getPastAssignees,
+      DeleteAssignee deleteAssignee,
       ListChangeComments listComments,
       ListChangeDrafts listDrafts,
       Check check,
@@ -136,15 +156,20 @@
     this.suggestReviewers = suggestReviewers;
     this.abandon = abandon;
     this.restore = restore;
+    this.updateByMerge = updateByMerge;
     this.submittedTogether = submittedTogether;
     this.publishDraftChange = publishDraftChange;
-    this.deleteDraftChange = deleteDraftChange;
+    this.deleteChange = deleteChange;
     this.getTopic = getTopic;
     this.putTopic = putTopic;
     this.postReviewers = postReviewers;
     this.changeJson = changeJson;
     this.postHashtags = postHashtags;
     this.getHashtags = getHashtags;
+    this.putAssignee = putAssignee;
+    this.getAssignee = getAssignee;
+    this.getPastAssignees = getPastAssignees;
+    this.deleteAssignee = deleteAssignee;
     this.listComments = listComments;
     this.listDrafts = listDrafts;
     this.check = check;
@@ -248,21 +273,40 @@
     }
   }
 
-  @SuppressWarnings("unchecked")
+  @Override
+  public ChangeInfo createMergePatchSet(MergePatchSetInput in)
+      throws RestApiException {
+    try {
+      return updateByMerge.apply(change, in).value();
+    } catch (IOException | UpdateException | InvalidChangeOperationException
+        | NoSuchChangeException | OrmException e) {
+      throw new RestApiException("Cannot update change by merge", e);
+    }
+  }
+
   @Override
   public List<ChangeInfo> submittedTogether() throws RestApiException {
-    try {
-      return (List<ChangeInfo>) submittedTogether.apply(change);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot query submittedTogether", e);
-    }
+    SubmittedTogetherInfo info = submittedTogether(
+        EnumSet.noneOf(ListChangesOption.class),
+        EnumSet.noneOf(SubmittedTogetherOption.class));
+    return info.changes;
   }
 
   @Override
   public SubmittedTogetherInfo submittedTogether(
       EnumSet<SubmittedTogetherOption> options) throws RestApiException {
+    return submittedTogether(EnumSet.noneOf(ListChangesOption.class), options);
+  }
+
+  @Override
+  public SubmittedTogetherInfo submittedTogether(
+      EnumSet<ListChangesOption> listOptions,
+      EnumSet<SubmittedTogetherOption> submitOptions) throws RestApiException {
     try {
-      return submittedTogether.apply(change, options);
+      return submittedTogether.get()
+          .addListChangesOption(listOptions)
+          .addSubmittedTogetherOption(submitOptions)
+          .applyInfo(change);
     } catch (IOException | OrmException e) {
       throw new RestApiException("Cannot query submittedTogether", e);
     }
@@ -280,7 +324,7 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteDraftChange.apply(change, null);
+      deleteChange.apply(change, null);
     } catch (UpdateException e) {
       throw new RestApiException("Cannot delete change", e);
     }
@@ -394,6 +438,45 @@
   }
 
   @Override
+  public AccountInfo setAssignee(AssigneeInput input)
+      throws RestApiException {
+    try {
+      return putAssignee.apply(change, input).value();
+    } catch (UpdateException | IOException | OrmException e) {
+      throw new RestApiException("Cannot set assignee", e);
+    }
+  }
+
+  @Override
+  public AccountInfo getAssignee() throws RestApiException {
+    try {
+      Response<AccountInfo> r = getAssignee.apply(change);
+      return r.isNone() ? null : r.value();
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot get assignee", e);
+    }
+  }
+
+  @Override
+  public List<AccountInfo> getPastAssignees() throws RestApiException {
+    try {
+      return getPastAssignees.apply(change).value();
+    } catch (Exception e) {
+      throw new RestApiException("Cannot get past assignees", e);
+    }
+  }
+
+  @Override
+  public AccountInfo deleteAssignee() throws RestApiException {
+    try {
+      Response<AccountInfo> r = deleteAssignee.apply(change, null);
+      return r.isNone() ? null : r.value();
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot delete assignee", e);
+    }
+  }
+
+  @Override
   public Map<String, List<CommentInfo>> comments() throws RestApiException {
     try {
       return listComments.apply(change);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
index 228dad6..bc38df2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
@@ -24,6 +24,7 @@
 
     factory(ChangeApiImpl.Factory.class);
     factory(CommentApiImpl.Factory.class);
+    factory(RobotCommentApiImpl.Factory.class);
     factory(DraftApiImpl.Factory.class);
     factory(RevisionApiImpl.Factory.class);
     factory(FileApiImpl.Factory.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
index a18c575..afda5fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.api.changes;
 
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -79,8 +80,13 @@
 
   @Override
   public void remove() throws RestApiException {
+    remove(new DeleteReviewerInput());
+  }
+
+  @Override
+  public void remove(DeleteReviewerInput input) throws RestApiException {
     try {
-      deleteReviewer.apply(reviewer, null);
+      deleteReviewer.apply(reviewer, input);
     } catch (UpdateException e) {
       throw new RestApiException("Cannot remove reviewer", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 6b5e83c..abbcef6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -26,12 +26,15 @@
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.RobotCommentApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -44,24 +47,30 @@
 import com.google.gerrit.server.change.DraftComments;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.Files;
+import com.google.gerrit.server.change.GetMergeList;
 import com.google.gerrit.server.change.GetPatch;
 import com.google.gerrit.server.change.GetRevisionActions;
 import com.google.gerrit.server.change.ListRevisionComments;
 import com.google.gerrit.server.change.ListRevisionDrafts;
+import com.google.gerrit.server.change.ListRobotComments;
 import com.google.gerrit.server.change.Mergeable;
 import com.google.gerrit.server.change.PostReview;
+import com.google.gerrit.server.change.PreviewSubmit;
 import com.google.gerrit.server.change.PublishDraftPatchSet;
 import com.google.gerrit.server.change.Rebase;
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.Reviewed;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.RobotComments;
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.change.TestSubmitType;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
 import org.eclipse.jgit.lib.Repository;
@@ -84,6 +93,7 @@
   private final Rebase rebase;
   private final RebaseUtil rebaseUtil;
   private final Submit submit;
+  private final PreviewSubmit submitPreview;
   private final PublishDraftPatchSet publish;
   private final Reviewed.PutReviewed putReviewed;
   private final Reviewed.DeleteReviewed deleteReviewed;
@@ -95,15 +105,19 @@
   private final Mergeable mergeable;
   private final FileApiImpl.Factory fileApi;
   private final ListRevisionComments listComments;
+  private final ListRobotComments listRobotComments;
   private final ListRevisionDrafts listDrafts;
   private final CreateDraftComment createDraft;
   private final DraftComments drafts;
   private final DraftApiImpl.Factory draftFactory;
   private final Comments comments;
   private final CommentApiImpl.Factory commentFactory;
+  private final RobotComments robotComments;
+  private final RobotCommentApiImpl.Factory robotCommentFactory;
   private final GetRevisionActions revisionActions;
   private final TestSubmitType testSubmitType;
   private final TestSubmitType.Get getSubmitType;
+  private final Provider<GetMergeList> getMergeList;
 
   @Inject
   RevisionApiImpl(GitRepositoryManager repoManager,
@@ -113,6 +127,7 @@
       Rebase rebase,
       RebaseUtil rebaseUtil,
       Submit submit,
+      PreviewSubmit submitPreview,
       PublishDraftPatchSet publish,
       Reviewed.PutReviewed putReviewed,
       Reviewed.DeleteReviewed deleteReviewed,
@@ -123,15 +138,19 @@
       Mergeable mergeable,
       FileApiImpl.Factory fileApi,
       ListRevisionComments listComments,
+      ListRobotComments listRobotComments,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
       DraftComments drafts,
       DraftApiImpl.Factory draftFactory,
       Comments comments,
       CommentApiImpl.Factory commentFactory,
+      RobotComments robotComments,
+      RobotCommentApiImpl.Factory robotCommentFactory,
       GetRevisionActions revisionActions,
       TestSubmitType testSubmitType,
       TestSubmitType.Get getSubmitType,
+      Provider<GetMergeList> getMergeList,
       @Assisted RevisionResource r) {
     this.repoManager = repoManager;
     this.changes = changes;
@@ -141,6 +160,7 @@
     this.rebaseUtil = rebaseUtil;
     this.review = review;
     this.submit = submit;
+    this.submitPreview = submitPreview;
     this.publish = publish;
     this.files = files;
     this.putReviewed = putReviewed;
@@ -150,15 +170,19 @@
     this.mergeable = mergeable;
     this.fileApi = fileApi;
     this.listComments = listComments;
+    this.robotComments = robotComments;
+    this.listRobotComments = listRobotComments;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
     this.drafts = drafts;
     this.draftFactory = draftFactory;
     this.comments = comments;
     this.commentFactory = commentFactory;
+    this.robotCommentFactory = robotCommentFactory;
     this.revisionActions = revisionActions;
     this.testSubmitType = testSubmitType;
     this.getSubmitType = getSubmitType;
+    this.getMergeList = getMergeList;
     this.revision = r;
   }
 
@@ -187,6 +211,12 @@
   }
 
   @Override
+  public BinaryResult submitPreview() throws RestApiException {
+    submitPreview.setFormat("zip");
+    return submitPreview.apply(revision);
+  }
+
+  @Override
   public void publish() throws RestApiException {
     try {
       publish.apply(revision, new PublishDraftPatchSet.Input());
@@ -264,7 +294,7 @@
       return ImmutableSet.copyOf((Iterable<String>) listFiles
           .setReviewed(true)
           .apply(revision).value());
-    } catch (OrmException | IOException e) {
+    } catch (OrmException | IOException | PatchListNotAvailableException e) {
       throw new RestApiException("Cannot list reviewed files", e);
     }
   }
@@ -293,7 +323,7 @@
   public Map<String, FileInfo> files() throws RestApiException {
     try {
       return (Map<String, FileInfo>)listFiles.apply(revision).value();
-    } catch (OrmException | IOException e) {
+    } catch (OrmException | IOException | PatchListNotAvailableException e) {
       throw new RestApiException("Cannot retrieve files", e);
     }
   }
@@ -304,7 +334,7 @@
     try {
       return (Map<String, FileInfo>) listFiles.setBase(base)
           .apply(revision).value();
-    } catch (OrmException | IOException e) {
+    } catch (OrmException | IOException | PatchListNotAvailableException e) {
       throw new RestApiException("Cannot retrieve files", e);
     }
   }
@@ -315,7 +345,7 @@
     try {
       return (Map<String, FileInfo>) listFiles.setParent(parentNum)
           .apply(revision).value();
-    } catch (OrmException | IOException e) {
+    } catch (OrmException | IOException | PatchListNotAvailableException e) {
       throw new RestApiException("Cannot retrieve files", e);
     }
   }
@@ -336,6 +366,15 @@
   }
 
   @Override
+  public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
+    try {
+      return listRobotComments.apply(revision);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comments", e);
+    }
+  }
+
+  @Override
   public List<CommentInfo> commentsAsList() throws RestApiException {
     try {
       return listComments.getComments(revision);
@@ -354,6 +393,15 @@
   }
 
   @Override
+  public List<RobotCommentInfo> robotCommentsAsList() throws RestApiException {
+    try {
+      return listRobotComments.getComments(revision);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comments", e);
+    }
+  }
+
+  @Override
   public List<CommentInfo> draftsAsList() throws RestApiException {
     try {
       return listDrafts.getComments(revision);
@@ -396,6 +444,16 @@
   }
 
   @Override
+  public RobotCommentApi robotComment(String id) throws RestApiException {
+    try {
+      return robotCommentFactory
+          .create(robotComments.parse(revision, IdString.fromDecoded(id)));
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comment", e);
+    }
+  }
+
+  @Override
   public BinaryResult patch() throws RestApiException {
     try {
       return getPatch.apply(revision);
@@ -405,6 +463,15 @@
   }
 
   @Override
+  public BinaryResult patch(String path) throws RestApiException {
+    try {
+      return getPatch.setPath(path).apply(revision);
+    } catch (IOException e) {
+      throw new RestApiException("Cannot get patch", e);
+    }
+  }
+
+  @Override
   public Map<String, ActionInfo> actions() throws RestApiException {
     return revisionActions.apply(revision).value();
   }
@@ -427,4 +494,21 @@
       throw new RestApiException("Cannot test submit type", e);
     }
   }
+
+  @Override
+  public MergeListRequest getMergeList() throws RestApiException {
+    return new MergeListRequest() {
+      @Override
+      public List<CommitInfo> get() throws RestApiException {
+        try {
+          GetMergeList gml = getMergeList.get();
+          gml.setUninterestingParent(getUninterestingParent());
+          gml.setAddLinks(getAddLinks());
+          return gml.apply(revision).value();
+        } catch (IOException e) {
+          throw new RestApiException("Cannot get merge list", e);
+        }
+      }
+    };
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
new file mode 100644
index 0000000..9169a4f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import com.google.gerrit.extensions.api.changes.RobotCommentApi;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.GetRobotComment;
+import com.google.gerrit.server.change.RobotCommentResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class RobotCommentApiImpl implements RobotCommentApi {
+  interface Factory {
+    RobotCommentApiImpl create(RobotCommentResource c);
+  }
+
+  private final GetRobotComment getComment;
+  private final RobotCommentResource comment;
+
+  @Inject
+  RobotCommentApiImpl(GetRobotComment getComment,
+      @Assisted RobotCommentResource comment) {
+    this.getComment = getComment;
+    this.comment = comment;
+  }
+
+  @Override
+  public RobotCommentInfo get() throws RestApiException {
+    try {
+      return getComment.apply(comment);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comment", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
index 8339ecf..f433d2b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -18,10 +18,12 @@
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.GetDiffPreferences;
 import com.google.gerrit.server.config.GetPreferences;
+import com.google.gerrit.server.config.GetServerInfo;
 import com.google.gerrit.server.config.SetDiffPreferences;
 import com.google.gerrit.server.config.SetPreferences;
 import com.google.inject.Inject;
@@ -37,16 +39,19 @@
   private final SetPreferences setPreferences;
   private final GetDiffPreferences getDiffPreferences;
   private final SetDiffPreferences setDiffPreferences;
+  private final GetServerInfo getServerInfo;
 
   @Inject
   ServerImpl(GetPreferences getPreferences,
       SetPreferences setPreferences,
       GetDiffPreferences getDiffPreferences,
-      SetDiffPreferences setDiffPreferences) {
+      SetDiffPreferences setDiffPreferences,
+      GetServerInfo getServerInfo) {
     this.getPreferences = getPreferences;
     this.setPreferences = setPreferences;
     this.getDiffPreferences = getDiffPreferences;
     this.setDiffPreferences = setDiffPreferences;
+    this.getServerInfo = getServerInfo;
   }
 
   @Override
@@ -55,6 +60,15 @@
   }
 
   @Override
+  public ServerInfo getInfo() throws RestApiException {
+    try {
+      return getServerInfo.apply(new ConfigResource());
+    } catch (IOException e) {
+      throw new RestApiException("Cannot get server info", e);
+    }
+  }
+
+  @Override
   public GeneralPreferencesInfo getDefaultPreferences()
       throws RestApiException {
     try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index 7f5f2d2..4cb96b3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.args4j;
 
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
index 3567811..354dc62 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
@@ -154,8 +154,8 @@
           }
         });
     } catch (PrivilegedActionException e) {
-      Throwables.propagateIfPossible(e.getException(), NamingException.class);
-      Throwables.propagateIfPossible(e.getException(), RuntimeException.class);
+      Throwables.throwIfInstanceOf(e.getException(), NamingException.class);
+      Throwables.throwIfInstanceOf(e.getException(), RuntimeException.class);
       LdapRealm.log.warn("Internal error", e.getException());
       return null;
     } finally {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
index 8dc7177..3dddf4d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import com.google.gerrit.reviewdb.client.AuthType;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.AuthException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapModule.java
index eaaafd6..217df2f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapModule.java
@@ -16,7 +16,6 @@
 
 import static java.util.concurrent.TimeUnit.HOURS;
 
-import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
@@ -27,6 +26,7 @@
 import com.google.inject.Scopes;
 import com.google.inject.TypeLiteral;
 
+import java.util.Optional;
 import java.util.Set;
 
 public class LdapModule extends CacheModule {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 30b08a6..4148e7a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -16,15 +16,15 @@
 
 import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
 
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AbstractRealm;
 import com.google.gerrit.server.account.AccountException;
@@ -48,6 +48,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 
@@ -67,7 +68,7 @@
   private final AuthConfig authConfig;
   private final EmailExpander emailExpander;
   private final LoadingCache<String, Optional<Account.Id>> usernameCache;
-  private final Set<Account.FieldName> readOnlyAccountFields;
+  private final Set<AccountFieldName> readOnlyAccountFields;
   private final boolean fetchMemberOfEagerly;
   private final Config config;
 
@@ -91,13 +92,13 @@
     this.readOnlyAccountFields = new HashSet<>();
 
     if (optdef(config, "accountFullName", "DEFAULT") != null) {
-      readOnlyAccountFields.add(Account.FieldName.FULL_NAME);
+      readOnlyAccountFields.add(AccountFieldName.FULL_NAME);
     }
     if (optdef(config, "accountSshUserName", "DEFAULT") != null) {
-      readOnlyAccountFields.add(Account.FieldName.USER_NAME);
+      readOnlyAccountFields.add(AccountFieldName.USER_NAME);
     }
     if (!authConfig.isAllowRegisterNewEmail()) {
-      readOnlyAccountFields.add(Account.FieldName.REGISTER_NEW_EMAIL);
+      readOnlyAccountFields.add(AccountFieldName.REGISTER_NEW_EMAIL);
     }
 
     fetchMemberOfEagerly = optional(config, "fetchMemberOfEagerly", true);
@@ -196,7 +197,7 @@
   }
 
   @Override
-  public boolean allowsEdit(final Account.FieldName field) {
+  public boolean allowsEdit(final AccountFieldName field) {
     return !readOnlyAccountFields.contains(field);
   }
 
@@ -294,7 +295,7 @@
     }
     try {
       Optional<Account.Id> id = usernameCache.get(accountName);
-      return id != null ? id.orNull() : null;
+      return id != null ? id.orElse(null) : null;
     } catch (ExecutionException e) {
       log.warn(String.format("Cannot lookup account %s in LDAP", accountName), e);
       return null;
@@ -312,13 +313,10 @@
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
       try (ReviewDb db = schema.open()) {
-        final AccountExternalId extId =
-            db.accountExternalIds().get(
-                new AccountExternalId.Key(SCHEME_GERRIT, username));
-        if (extId != null) {
-          return Optional.of(extId.getAccountId());
-        }
-        return Optional.absent();
+        return Optional.ofNullable(
+                db.accountExternalIds().get(
+                    new AccountExternalId.Key(SCHEME_GERRIT, username)))
+            .map(AccountExternalId::getAccountId);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
index c07b4c8..6b92289 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
@@ -17,9 +17,9 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
 import com.google.gerrit.server.account.AbstractRealm;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -37,7 +37,7 @@
 @Singleton
 public class OAuthRealm extends AbstractRealm {
   private final DynamicMap<OAuthLoginProvider> loginProviders;
-  private final Set<FieldName> editableAccountFields;
+  private final Set<AccountFieldName> editableAccountFields;
 
   @Inject
   OAuthRealm(DynamicMap<OAuthLoginProvider> loginProviders,
@@ -46,17 +46,17 @@
     this.editableAccountFields = new HashSet<>();
     // User name should be always editable, because not all OAuth providers
     // expose them
-    editableAccountFields.add(FieldName.USER_NAME);
+    editableAccountFields.add(AccountFieldName.USER_NAME);
     if (config.getBoolean("oauth", null, "allowEditFullName", false)) {
-      editableAccountFields.add(FieldName.FULL_NAME);
+      editableAccountFields.add(AccountFieldName.FULL_NAME);
     }
     if (config.getBoolean("oauth", null, "allowRegisterNewEmail", false)) {
-      editableAccountFields.add(FieldName.REGISTER_NEW_EMAIL);
+      editableAccountFields.add(AccountFieldName.REGISTER_NEW_EMAIL);
     }
   }
 
   @Override
-  public boolean allowsEdit(FieldName field) {
+  public boolean allowsEdit(AccountFieldName field) {
     return editableAccountFields.contains(field);
   }
 
@@ -106,12 +106,12 @@
     }
     if (!Strings.isNullOrEmpty(userInfo.getEmailAddress())
         && (Strings.isNullOrEmpty(who.getUserName())
-            || !allowsEdit(FieldName.REGISTER_NEW_EMAIL))) {
+            || !allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL))) {
       who.setEmailAddress(userInfo.getEmailAddress());
     }
     if (!Strings.isNullOrEmpty(userInfo.getDisplayName())
         && (Strings.isNullOrEmpty(who.getDisplayName())
-            || !allowsEdit(FieldName.FULL_NAME))) {
+            || !allowsEdit(AccountFieldName.FULL_NAME))) {
       who.setDisplayName(userInfo.getDisplayName());
     }
     return who;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheBinding.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheBinding.java
index 7062871..343827c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheBinding.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheBinding.java
@@ -26,6 +26,9 @@
   /** Set the total size of the cache. */
   CacheBinding<K, V> maximumWeight(long weight);
 
+  /** Set the total on-disk limit of the cache */
+  CacheBinding<K, V> diskLimit(long limit);
+
   /** Set the time an element lives before being expired. */
   CacheBinding<K, V> expireAfterWrite(long duration, TimeUnit durationUnits);
 
@@ -39,6 +42,7 @@
   TypeLiteral<K> keyType();
   TypeLiteral<V> valueType();
   long maximumWeight();
+  long diskLimit();
   @Nullable Long expireAfterWrite(TimeUnit unit);
   @Nullable Weigher<K, V> weigher();
   @Nullable CacheLoader<K, V> loader();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheProvider.java
index 6d9ae0f..c73760c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -38,6 +38,7 @@
   private final TypeLiteral<V> valType;
   private boolean persist;
   private long maximumWeight;
+  private long diskLimit;
   private Long expireAfterWrite;
   private Provider<CacheLoader<K, V>> loader;
   private Provider<Weigher<K, V>> weigher;
@@ -86,6 +87,15 @@
   }
 
   @Override
+  public CacheBinding<K, V> diskLimit(long limit) {
+    Preconditions.checkState(!frozen, "binding frozen, cannot be modified");
+    Preconditions.checkState(persist,
+        "diskLimit supported for persistent caches only");
+    diskLimit = limit;
+    return this;
+  }
+
+  @Override
   public CacheBinding<K, V> expireAfterWrite(long duration, TimeUnit unit) {
     Preconditions.checkState(!frozen, "binding frozen, cannot be modified");
     expireAfterWrite = SECONDS.convert(duration, unit);
@@ -130,6 +140,14 @@
   }
 
   @Override
+  public long diskLimit() {
+    if (diskLimit > 0) {
+      return diskLimit;
+    }
+    return 128 << 20;
+  }
+
+  @Override
   @Nullable
   public Long expireAfterWrite(TimeUnit unit) {
    return expireAfterWrite != null
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
index adbcf22..9c67fe0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
@@ -28,9 +28,9 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
@@ -38,8 +38,8 @@
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.mail.AbandonedSender;
-import com.google.gerrit.server.mail.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
@@ -50,6 +50,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.Collection;
+
 @Singleton
 public class Abandon implements RestModifyView<ChangeResource, AbandonInput>,
     UiAction<ChangeResource> {
@@ -91,6 +93,11 @@
     return json.create(ChangeJson.NO_OPTIONS).format(change);
   }
 
+  public Change abandon(ChangeControl control)
+      throws RestApiException, UpdateException {
+    return abandon(control, "", NotifyHandling.ALL);
+  }
+
   public Change abandon(ChangeControl control, String msgTxt)
       throws RestApiException, UpdateException {
     return abandon(control, msgTxt, NotifyHandling.ALL);
@@ -98,29 +105,69 @@
 
   public Change abandon(ChangeControl control, String msgTxt,
       NotifyHandling notifyHandling) throws RestApiException, UpdateException {
-    CurrentUser user = control.getUser();
-    Account account = user.isIdentifiedUser()
-        ? user.asIdentifiedUser().getAccount()
-        : null;
-    Op op = new Op(msgTxt, account, notifyHandling);
-    try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
-        control.getProject().getNameKey(), user, TimeUtil.nowTs())) {
+    Op op = new Op(msgTxt, notifyHandling);
+    try (BatchUpdate u =
+        batchUpdateFactory.create(
+            dbProvider.get(),
+            control.getProject().getNameKey(),
+            control.getUser(),
+            TimeUtil.nowTs())) {
       u.addOp(control.getId(), op).execute();
     }
     return op.change;
   }
 
+  /**
+   * If an extension has more than one changes to abandon that belong to the
+   * same project, they should use the batch instead of abandoning one by one.
+   * <p>
+   * It's the caller's responsibility to ensure that all jobs inside the same
+   * batch have the matching project from its ChangeControl. Violations will
+   * result in a ResourceConflictException.
+   */
+  public void batchAbandon(Project.NameKey project, CurrentUser user,
+      Collection<ChangeControl> controls, String msgTxt,
+      NotifyHandling notifyHandling) throws RestApiException, UpdateException {
+    if (controls.isEmpty()) {
+      return;
+    }
+    try (BatchUpdate u = batchUpdateFactory.create(
+        dbProvider.get(), project, user, TimeUtil.nowTs())) {
+      for (ChangeControl control : controls) {
+        if (!project.equals(control.getProject().getNameKey())) {
+          throw new ResourceConflictException(
+              String.format(
+                  "Project name \"%s\" doesn't match \"%s\"",
+                  control.getProject().getNameKey().get(),
+                  project.get()));
+        }
+        u.addOp(control.getId(), new Op(msgTxt, notifyHandling));
+      }
+      u.execute();
+    }
+  }
+
+  public void batchAbandon(Project.NameKey project, CurrentUser user,
+      Collection<ChangeControl> controls, String msgTxt)
+      throws RestApiException, UpdateException {
+    batchAbandon(project, user, controls, msgTxt, NotifyHandling.ALL);
+  }
+
+  public void batchAbandon(Project.NameKey project, CurrentUser user,
+      Collection<ChangeControl> controls)
+      throws RestApiException, UpdateException {
+    batchAbandon(project, user, controls, "", NotifyHandling.ALL);
+  }
+
   private class Op extends BatchUpdate.Op {
-    private final Account account;
     private final String msgTxt;
+    private final NotifyHandling notifyHandling;
 
     private Change change;
     private PatchSet patchSet;
     private ChangeMessage message;
-    private NotifyHandling notifyHandling;
 
-    private Op(String msgTxt, Account account, NotifyHandling notifyHandling) {
-      this.account = account;
+    private Op(String msgTxt, NotifyHandling notifyHandling) {
       this.msgTxt = msgTxt;
       this.notifyHandling = notifyHandling;
     }
@@ -155,19 +202,15 @@
         msg.append(msgTxt.trim());
       }
 
-      ChangeMessage message = new ChangeMessage(
-          new ChangeMessage.Key(
-              change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          account != null ? account.getId() : null,
-          ctx.getWhen(),
-          change.currentPatchSetId());
-      message.setMessage(msg.toString());
-      return message;
+      return ChangeMessagesUtil.newMessage(
+          ctx, msg.toString(), ChangeMessagesUtil.TAG_ABANDON);
     }
 
     @Override
     public void postUpdate(Context ctx) throws OrmException {
+      Account account = ctx.getUser().isIdentifiedUser()
+          ? ctx.getUser().asIdentifiedUser().getAccount()
+          : null;
       try {
         ReplyToChangeSender cm =
             abandonedSenderFactory.create(ctx.getProject(), change.getId());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
index 60d9c08..d1cc73b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.project.ChangeControl;
@@ -29,6 +31,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 
@@ -37,10 +41,10 @@
   private static final Logger log = LoggerFactory.getLogger(AbandonUtil.class);
 
   private final ChangeCleanupConfig cfg;
-  private final InternalUser.Factory internalUserFactory;
   private final ChangeQueryProcessor queryProcessor;
   private final ChangeQueryBuilder queryBuilder;
   private final Abandon abandon;
+  private final InternalUser internalUser;
 
   @Inject
   AbandonUtil(
@@ -50,10 +54,10 @@
       ChangeQueryBuilder queryBuilder,
       Abandon abandon) {
     this.cfg = cfg;
-    this.internalUserFactory = internalUserFactory;
     this.queryProcessor = queryProcessor;
     this.queryBuilder = queryBuilder;
     this.abandon = abandon;
+    internalUser = internalUserFactory.create();
   }
 
   public void abandonInactiveOpenChanges() {
@@ -68,42 +72,64 @@
       if (!cfg.getAbandonIfMergeable()) {
         query += " -is:mergeable";
       }
-      List<ChangeData> changesToAbandon = queryProcessor.enforceVisibility(false)
-          .query(queryBuilder.parse(query)).entities();
-      int count = 0;
+
+      List<ChangeData> changesToAbandon =
+          queryProcessor
+              .enforceVisibility(false)
+              .query(queryBuilder.parse(query))
+              .entities();
+      ImmutableMultimap.Builder<Project.NameKey, ChangeControl> builder =
+          ImmutableMultimap.builder();
       for (ChangeData cd : changesToAbandon) {
+        ChangeControl control = cd.changeControl(internalUser);
+        builder.put(control.getProject().getNameKey(), control);
+      }
+
+      int count = 0;
+      Multimap<Project.NameKey, ChangeControl> abandons = builder.build();
+      String message = cfg.getAbandonMessage();
+      for (Project.NameKey project : abandons.keySet()) {
+        Collection<ChangeControl> changes =
+            getValidChanges(abandons.get(project), query);
         try {
-          if (noNeedToAbandon(cd, query)){
-            log.debug("Change data \"{}\" does not satisfy the query \"{}\" any"
-                + " more, hence skipping it in clean up", cd, query);
-            continue;
-          }
-          abandon.abandon(changeControl(cd), cfg.getAbandonMessage());
-          count++;
-        } catch (ResourceConflictException e) {
-          // Change was already merged or abandoned.
+          abandon.batchAbandon(project, internalUser, changes, message);
+          count += changes.size();
         } catch (Throwable e) {
-          log.error(String.format(
-              "Failed to auto-abandon inactive open change %d.",
-                  cd.getId().get()), e);
+          StringBuilder msg =
+              new StringBuilder("Failed to auto-abandon inactive change(s):");
+          for (ChangeControl change : changes) {
+            msg.append(" ").append(change.getId().get());
+          }
+          msg.append(".");
+          log.error(msg.toString(), e);
         }
       }
       log.info(String.format("Auto-Abandoned %d of %d changes.",
           count, changesToAbandon.size()));
     } catch (QueryParseException | OrmException e) {
-      log.error("Failed to query inactive open changes for auto-abandoning.", e);
+      log.error(
+          "Failed to query inactive open changes for auto-abandoning.", e);
     }
   }
 
-  private boolean noNeedToAbandon(ChangeData cd, String query)
+  private Collection<ChangeControl> getValidChanges(
+      Collection<ChangeControl> changeControls, String query)
       throws OrmException, QueryParseException {
-    String newQuery = query + " change:" + cd.getId();
-    List<ChangeData> changesToAbandon = queryProcessor.enforceVisibility(false)
-        .query(queryBuilder.parse(newQuery)).entities();
-    return changesToAbandon.isEmpty();
-  }
-
-  private ChangeControl changeControl(ChangeData cd) throws OrmException {
-    return cd.changeControl(internalUserFactory.create());
+    Collection<ChangeControl> validChanges = new ArrayList<>();
+    for (ChangeControl cc : changeControls) {
+      String newQuery = query + " change:" + cc.getId();
+      List<ChangeData> changesToAbandon =
+          queryProcessor.enforceVisibility(false)
+              .query(queryBuilder.parse(newQuery)).entities();
+      if (!changesToAbandon.isEmpty()) {
+        validChanges.add(cc);
+      } else {
+        log.debug(
+            "Change data with id \"{}\" does not satisfy the query \"{}\""
+                + " any more, hence skipping it in clean up",
+            cc.getId(), query);
+      }
+    }
+    return validChanges;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AllowedFormats.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AllowedFormats.java
new file mode 100644
index 0000000..756ce88
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/AllowedFormats.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.gerrit.server.config.DownloadConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+@Singleton
+public class AllowedFormats {
+  final ImmutableMap<String, ArchiveFormat> extensions;
+  final ImmutableSet<ArchiveFormat> allowed;
+
+  @Inject
+  AllowedFormats(DownloadConfig cfg) {
+    Map<String, ArchiveFormat> exts = new HashMap<>();
+    for (ArchiveFormat format : cfg.getArchiveFormats()) {
+      for (String ext : format.getSuffixes()) {
+        exts.put(ext, format);
+      }
+      exts.put(format.name().toLowerCase(), format);
+    }
+    extensions = ImmutableMap.copyOf(exts);
+
+    // Zip is not supported because it may be interpreted by a Java plugin as a
+    // valid JAR file, whose code would have access to cookies on the domain.
+    allowed = Sets.immutableEnumSet(
+        Iterables.filter(cfg.getArchiveFormats(), f -> f != ArchiveFormat.ZIP));
+  }
+
+  public Set<ArchiveFormat> getAllowed() {
+    return allowed;
+  }
+
+  public ImmutableMap<String, ArchiveFormat> getExtensions() {
+    return extensions;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
index 335f201..9b8f2b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.server.change;
 
+import org.apache.commons.compress.archivers.ArchiveEntry;
+import org.apache.commons.compress.archivers.ArchiveOutputStream;
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
 import org.eclipse.jgit.api.ArchiveCommand;
 import org.eclipse.jgit.archive.TarFormat;
 import org.eclipse.jgit.archive.Tbz2Format;
@@ -21,12 +25,40 @@
 import org.eclipse.jgit.archive.TxzFormat;
 import org.eclipse.jgit.archive.ZipFormat;
 
+import java.io.IOException;
+import java.io.OutputStream;
+
 public enum ArchiveFormat {
-  TGZ("application/x-gzip", new TgzFormat()),
-  TAR("application/x-tar", new TarFormat()),
-  TBZ2("application/x-bzip2", new Tbz2Format()),
-  TXZ("application/x-xz", new TxzFormat()),
-  ZIP("application/x-zip", new ZipFormat());
+  TGZ("application/x-gzip", new TgzFormat()) {
+    @Override
+    public ArchiveEntry prepareArchiveEntry(String fileName) {
+      return new TarArchiveEntry(fileName);
+    }
+  },
+  TAR("application/x-tar", new TarFormat()) {
+    @Override
+    public ArchiveEntry prepareArchiveEntry(String fileName) {
+      return new TarArchiveEntry(fileName);
+    }
+  },
+  TBZ2("application/x-bzip2", new Tbz2Format()) {
+    @Override
+    public ArchiveEntry prepareArchiveEntry(String fileName) {
+      return new TarArchiveEntry(fileName);
+    }
+  },
+  TXZ("application/x-xz", new TxzFormat()) {
+    @Override
+    public ArchiveEntry prepareArchiveEntry(String fileName) {
+      return new TarArchiveEntry(fileName);
+    }
+  },
+  ZIP("application/x-zip", new ZipFormat()) {
+    @Override
+    public ArchiveEntry prepareArchiveEntry(String fileName) {
+      return new ZipArchiveEntry(fileName);
+    }
+  };
 
   private final ArchiveCommand.Format<?> format;
   private final String mimeType;
@@ -52,4 +84,11 @@
   Iterable<String> getSuffixes() {
     return format.suffixes();
   }
-}
+
+  public ArchiveOutputStream createArchiveOutputStream(OutputStream o)
+      throws IOException {
+    return (ArchiveOutputStream)this.format.createArchiveOutputStream(o);
+  }
+
+  public abstract ArchiveEntry prepareArchiveEntry(final String fileName);
+}
\ No newline at end of file
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
index 878cc81..ad07e30 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
 import com.google.gerrit.common.Nullable;
@@ -65,6 +64,7 @@
 
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
 
 @Singleton
 public class ChangeEdits implements
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
index 0d7a1bf..975f459 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.Change.INITIAL_PATCH_SET_ID;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.FooterConstants;
@@ -33,12 +34,11 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
-import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
@@ -47,7 +47,8 @@
 import com.google.gerrit.server.git.SendEmailExecutor;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.CreateChangeSender;
+import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
@@ -62,7 +63,6 @@
 import com.google.inject.assistedinject.Assisted;
 
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.util.ChangeIdUtil;
@@ -86,6 +86,7 @@
       LoggerFactory.getLogger(ChangeInserter.class);
 
   private final ProjectControl.GenericFactory projectControlFactory;
+  private final IdentifiedUser.GenericFactory userFactory;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
@@ -128,6 +129,7 @@
 
   @Inject
   ChangeInserter(ProjectControl.GenericFactory projectControlFactory,
+      IdentifiedUser.GenericFactory userFactory,
       ChangeControl.GenericFactory changeControlFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
@@ -142,6 +144,7 @@
       @Assisted RevCommit commit,
       @Assisted String refName) {
     this.projectControlFactory = projectControlFactory;
+    this.userFactory = userFactory;
     this.changeControlFactory = changeControlFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
@@ -353,21 +356,42 @@
     update.fixStatus(change.getStatus());
 
     LabelTypes labelTypes = ctl.getProjectControl().getLabelTypes();
-    approvalsUtil.addReviewers(db, update, labelTypes, change,
-        patchSet, patchSetInfo, reviewers, Collections.<Account.Id> emptySet());
-    approvalsUtil.addApprovals(db, update, labelTypes, patchSet,
-        ctx.getControl(), approvals);
+    approvalsUtil.addReviewers(db, update, labelTypes, change, patchSet,
+        patchSetInfo,
+        filterOnChangeVisibility(db, ctx.getNotes(), reviewers),
+        Collections.<Account.Id> emptySet());
+    approvalsUtil.addApprovalsForNewPatchSet(
+        db, update, labelTypes, patchSet, ctx.getControl(), approvals);
     if (message != null) {
-      changeMessage =
-          new ChangeMessage(new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(db)), ctx.getAccountId(),
-              patchSet.getCreatedOn(), patchSet.getId());
-      changeMessage.setMessage(message);
+      changeMessage = ChangeMessagesUtil.newMessage(
+          db, patchSet.getId(), ctx.getUser(), patchSet.getCreatedOn(),
+          message, ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
       cmUtil.addChangeMessage(db, update, changeMessage);
     }
     return true;
   }
 
+  private Set<Account.Id> filterOnChangeVisibility(final ReviewDb db,
+      final ChangeNotes notes, Set<Account.Id> accounts) {
+    return accounts.stream()
+        .filter(
+            accountId -> {
+              try {
+                IdentifiedUser user = userFactory.create(accountId);
+                return changeControlFactory.controlFor(notes, user)
+                    .isVisible(db);
+              } catch (OrmException | NoSuchChangeException e) {
+                log.warn(
+                    String.format(
+                        "Failed to check if account %d can see change %d",
+                        accountId.get(), notes.getChangeId().get()),
+                    e);
+                return false;
+              }
+            })
+        .collect(toSet());
+  }
+
   @Override
   public void postUpdate(Context ctx) throws OrmException, NoSuchChangeException {
     if (sendMail) {
@@ -440,9 +464,6 @@
     try {
       RefControl refControl = projectControlFactory
           .controlFor(ctx.getProject(), ctx.getUser()).controlForRef(refName);
-      CommitValidators cv = commitValidatorsFactory.create(
-          refControl, new NoSshInfo(), ctx.getRepository());
-
       String refName = psId.toRefName();
       CommitReceivedEvent event = new CommitReceivedEvent(
           new ReceiveCommand(
@@ -453,19 +474,10 @@
           change.getDest().get(),
           commit,
           ctx.getIdentifiedUser());
-
-      switch (validatePolicy) {
-      case RECEIVE_COMMITS:
-        NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(
-            ctx.getRepository(), ctx.getRevWalk());
-        cv.validateForReceiveCommits(event, rejectCommits);
-        break;
-      case GERRIT:
-        cv.validateForGerritCommits(event);
-        break;
-      case NONE:
-        break;
-      }
+      commitValidatorsFactory
+          .create(
+              validatePolicy, refControl, new NoSshInfo(), ctx.getRepository())
+          .validate(event);
     } catch (CommitValidationException e) {
       throw new ResourceConflictException(e.getFullMessage());
     } catch (NoSuchProjectException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index 07714dc..1684e76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_COMMITS;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_FILES;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
@@ -32,19 +33,20 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
+import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.CommonConverters.toGitPerson;
+import static java.util.stream.Collectors.toList;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Optional;
 import com.google.common.base.Throwables;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
@@ -100,13 +102,15 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LabelNormalizer;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.QueryResult;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
@@ -133,14 +137,34 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
 
 public class ChangeJson {
   private static final Logger log = LoggerFactory.getLogger(ChangeJson.class);
+
+  // Submit rule options in this class should always use fastEvalLabels for
+  // efficiency reasons. Callers that care about submittability after taking
+  // vote squashing into account should be looking at the submit action.
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
+      ChangeField.SUBMIT_RULE_OPTIONS_LENIENT
+          .toBuilder()
+          .fastEvalLabels(true)
+          .build();
+
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
+      ChangeField.SUBMIT_RULE_OPTIONS_STRICT
+          .toBuilder()
+          .fastEvalLabels(true)
+          .build();
+
   public static final Set<ListChangesOption> NO_OPTIONS =
       Collections.emptySet();
 
+  public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
+      ImmutableSet.of(ALL_REVISIONS, MESSAGES);
+
   public interface Factory {
     ChangeJson create(Set<ListChangesOption> options);
   }
@@ -167,9 +191,10 @@
   private final ChangeNotes.Factory notesFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeKindCache changeKindCache;
+  private final ChangeIndexCollection indexes;
 
+  private boolean lazyLoad = true;
   private AccountLoader accountLoader;
-  private Map<Change.Id, List<SubmitRecord>> submitRecords;
   private FixInput fix;
 
   @AssistedInject
@@ -195,6 +220,7 @@
       ChangeNotes.Factory notesFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeKindCache changeKindCache,
+      ChangeIndexCollection indexes,
       @Assisted Set<ListChangesOption> options) {
     this.db = db;
     this.labelNormalizer = ln;
@@ -217,11 +243,17 @@
     this.notesFactory = notesFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeKindCache = changeKindCache;
+    this.indexes = indexes;
     this.options = options.isEmpty()
         ? EnumSet.noneOf(ListChangesOption.class)
         : EnumSet.copyOf(options);
   }
 
+  public ChangeJson lazyLoad(boolean load) {
+    lazyLoad = load;
+    return this;
+  }
+
   public ChangeJson fix(FixInput fix) {
     this.fix = fix;
     return this;
@@ -250,7 +282,7 @@
   }
 
   public ChangeInfo format(ChangeData cd) throws OrmException {
-    return format(cd, Optional.<PatchSet.Id> absent(), true);
+    return format(cd, Optional.empty(), true);
   }
 
   private ChangeInfo format(ChangeData cd, Optional<PatchSet.Id> limitToPsId,
@@ -267,7 +299,7 @@
     } catch (PatchListNotAvailableException | GpgException | OrmException
         | IOException | RuntimeException e) {
       if (!has(CHECK)) {
-        Throwables.propagateIfPossible(e, OrmException.class);
+        Throwables.throwIfInstanceOf(e, OrmException.class);
         throw new OrmException(e);
       }
       return checkOnly(cd);
@@ -282,13 +314,8 @@
   public List<List<ChangeInfo>> formatQueryResults(
       List<QueryResult<ChangeData>> in) throws OrmException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    ensureLoaded(FluentIterable.from(in).transformAndConcat(
-        new Function<QueryResult<ChangeData>, List<ChangeData>>() {
-          @Override
-          public List<ChangeData> apply(QueryResult<ChangeData> in) {
-            return in.entities();
-          }
-        }));
+    ensureLoaded(
+        FluentIterable.from(in).transformAndConcat(QueryResult::entities));
 
     List<List<ChangeInfo>> res = Lists.newArrayListWithCapacity(in.size());
     Map<Change.Id, ChangeInfo> out = new HashMap<>();
@@ -316,16 +343,22 @@
   }
 
   private void ensureLoaded(Iterable<ChangeData> all) throws OrmException {
-    ChangeData.ensureChangeLoaded(all);
-    if (has(ALL_REVISIONS)) {
-      ChangeData.ensureAllPatchSetsLoaded(all);
-    } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
-      ChangeData.ensureCurrentPatchSetLoaded(all);
+    if (lazyLoad) {
+      ChangeData.ensureChangeLoaded(all);
+      if (has(ALL_REVISIONS)) {
+        ChangeData.ensureAllPatchSetsLoaded(all);
+      } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
+        ChangeData.ensureCurrentPatchSetLoaded(all);
+      }
+      if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
+        ChangeData.ensureReviewedByLoadedForOpenChanges(all);
+      }
+      ChangeData.ensureCurrentApprovalsLoaded(all);
+    } else {
+      for (ChangeData cd : all) {
+        cd.setLazyLoad(false);
+      }
     }
-    if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
-      ChangeData.ensureReviewedByLoadedForOpenChanges(all);
-    }
-    ChangeData.ensureCurrentApprovalsLoaded(all);
   }
 
   private boolean has(ListChangesOption option) {
@@ -339,7 +372,7 @@
       ChangeInfo i = out.get(cd.getId());
       if (i == null) {
         try {
-          i = toChangeInfo(cd, Optional.<PatchSet.Id> absent());
+          i = toChangeInfo(cd, Optional.empty());
         } catch (PatchListNotAvailableException | GpgException | OrmException
             | IOException | RuntimeException e) {
           if (has(CHECK)) {
@@ -419,16 +452,23 @@
     out.project = in.getProject().get();
     out.branch = in.getDest().getShortName();
     out.topic = in.getTopic();
+    if (indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE)) {
+      if (in.getAssignee() != null) {
+        out.assignee = accountLoader.get(in.getAssignee());
+      }
+    }
     out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
-    if (in.getStatus() != Change.Status.MERGED) {
+    if (in.getStatus().isOpen()) {
       SubmitTypeRecord str = cd.submitTypeRecord();
       if (str.isOk()) {
         out.submitType = str.type;
       }
       out.mergeable = cd.isMergeable();
+      if (has(SUBMITTABLE)) {
+        out.submittable = submittable(cd);
+      }
     }
-    out.submittable = Submit.submittable(cd);
     Optional<ChangedLines> changedLines = cd.changedLines();
     if (changedLines.isPresent()) {
       out.insertions = changedLines.get().insertions;
@@ -464,7 +504,10 @@
       // list permitted labels, since users can't vote on those patch sets.
       if (!limitToPsId.isPresent()
           || limitToPsId.get().equals(in.currentPatchSetId())) {
-        out.permittedLabels = permittedLabels(ctl, cd);
+        out.permittedLabels =
+            cd.change().getStatus() != Change.Status.ABANDONED
+                ? permittedLabels(ctl, cd)
+                : ImmutableMap.of();
       }
       out.removableReviewers = removableReviewers(ctl, out.labels.values());
 
@@ -529,23 +572,14 @@
     return result;
   }
 
+  private boolean submittable(ChangeData cd) throws OrmException {
+    return SubmitRecord.findOkRecord(
+            cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT))
+        .isPresent();
+  }
+
   private List<SubmitRecord> submitRecords(ChangeData cd) throws OrmException {
-    // Maintain our own cache rather than using cd.getSubmitRecords(),
-    // since the latter may not have used the same values for
-    // fastEvalLabels/allowDraft/etc.
-    // TODO(dborowitz): Handle this better at the ChangeData level.
-    if (submitRecords == null) {
-      submitRecords = new HashMap<>();
-    }
-    List<SubmitRecord> records = submitRecords.get(cd.getId());
-    if (records == null) {
-      records = new SubmitRuleEvaluator(cd)
-        .setFastEvalLabels(true)
-        .setAllowDraft(true)
-        .evaluate();
-      submitRecords.put(cd.getId(), records);
-    }
-    return records;
+    return cd.submitRecords(SUBMIT_RULE_OPTIONS_LENIENT);
   }
 
   private Map<String, LabelInfo> labelsFor(ChangeControl ctl,
@@ -563,7 +597,7 @@
       ? labelsForOpenChange(ctl, cd, labelTypes, standard, detailed)
       : labelsForClosedChange(cd, labelTypes, standard, detailed);
     return ImmutableMap.copyOf(
-        Maps.transformValues(withStatus, LabelWithStatus.TO_LABEL_INFO));
+        Maps.transformValues(withStatus, LabelWithStatus::label));
   }
 
   private Map<String, LabelWithStatus> labelsForOpenChange(ChangeControl ctl,
@@ -659,11 +693,15 @@
 
   private void setAllApprovals(ChangeControl baseCtrl, ChangeData cd,
       Map<String, LabelWithStatus> labels) throws OrmException {
+    Change.Status status = cd.change().getStatus();
+    checkState(status.isOpen(),
+        "should not call setAllApprovals on %s change", status);
+
     // Include a user in the output for this label if either:
     //  - They are an explicit reviewer.
     //  - They ever voted on this change.
     Set<Account.Id> allUsers = new HashSet<>();
-    allUsers.addAll(cd.reviewers().all());
+    allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
     for (PatchSetApproval psa : cd.approvals().values()) {
       allUsers.add(psa.getAccountId());
     }
@@ -698,6 +736,9 @@
           }
           tag = psa.getTag();
           date = psa.getGranted();
+          if (psa.isPostSubmit()) {
+            log.warn("unexpected post-submit approval on open change: {}", psa);
+          }
         } else {
           // Either the user cannot vote on this label, or they were added as a
           // reviewer but have not responded yet. Explicitly check whether the
@@ -730,8 +771,6 @@
       }
     }
 
-    // We can only approximately reconstruct what the submit rule evaluator
-    // would have done. These should really come from a stored submit record.
     Set<String> labelNames = new HashSet<>();
     Multimap<Account.Id, PatchSetApproval> current = HashMultimap.create();
     for (PatchSetApproval a : cd.currentApprovals()) {
@@ -745,16 +784,39 @@
       }
     }
 
-    // Don't use Maps.newTreeMap(Comparator) due to OpenJDK bug 100167.
-    Map<String, LabelWithStatus> labels =
-        new TreeMap<>(labelTypes.nameComparator());
-    for (String name : labelNames) {
-      LabelType type = labelTypes.byLabel(name);
-      LabelWithStatus l = LabelWithStatus.create(new LabelInfo(), null);
-      if (detailed) {
-        setLabelValues(type, l);
+    Map<String, LabelWithStatus> labels;
+    if (cd.change().getStatus() == Change.Status.MERGED) {
+      // Since voting on merged changes is allowed all labels which apply to
+      // the change must be returned. All applying labels can be retrieved from
+      // the submit records, which is what initLabels does.
+      // It's not possible to only compute the labels based on the approvals
+      // since merged changes may not have approvals for all labels (e.g. if not
+      // all labels are required for submit or if the change was auto-closed due
+      // to direct push or if new labels were defined after the change was
+      // merged).
+      labels = initLabels(cd, labelTypes, standard);
+
+      // Also include all labels for which approvals exists. E.g. there can be
+      // approvals for labels that are ignored by a Prolog submit rule and hence
+      // it wouldn't be included in the submit records.
+      for (String name : labelNames) {
+        if (!labels.containsKey(name)) {
+          labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
+        }
       }
-      labels.put(type.getName(), l);
+    } else {
+      // For abandoned changes return only labels for which approvals exist.
+      // Other labels are not needed since voting on abandoned changes is not
+      // allowed.
+      labels = new TreeMap<>(labelTypes.nameComparator());
+      for (String name : labelNames) {
+        labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
+      }
+    }
+
+    if (detailed) {
+      labels.entrySet().stream().forEach(
+          e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
     }
 
     for (Account.Id accountId : allUsers) {
@@ -780,6 +842,9 @@
           info.value = Integer.valueOf(val);
           info.date = psa.getGranted();
           info.tag = psa.getTag();
+          if (psa.isPostSubmit()) {
+            info.postSubmit = true;
+          }
         }
         if (!standard) {
           continue;
@@ -916,22 +981,25 @@
 
   private Collection<AccountInfo> toAccountInfo(
       Collection<Account.Id> accounts) {
-    return FluentIterable.from(accounts)
-        .transform(new Function<Account.Id, AccountInfo>() {
-          @Override
-          public AccountInfo apply(Account.Id id) {
-            return accountLoader.get(id);
-          }
-        })
-        .toSortedList(AccountInfoComparator.ORDER_NULLS_FIRST);
+    return accounts.stream()
+        .map(accountLoader::get)
+        .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
+        .collect(toList());
+  }
+
+  @Nullable
+  private Repository openRepoIfNecessary(ChangeControl ctl) throws IOException {
+    if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
+      return repoManager.openRepository(ctl.getProject().getNameKey());
+    }
+    return null;
   }
 
   private Map<String, RevisionInfo> revisions(ChangeControl ctl, ChangeData cd,
       Map<PatchSet.Id, PatchSet> map) throws PatchListNotAvailableException,
       GpgException, OrmException, IOException {
     Map<String, RevisionInfo> res = new LinkedHashMap<>();
-    try (Repository repo =
-        repoManager.openRepository(ctl.getProject().getNameKey())) {
+    try (Repository repo = openRepoIfNecessary(ctl)) {
       for (PatchSet in : map.values()) {
         if ((has(ALL_REVISIONS)
             || in.getId().equals(ctl.getChange().currentPatchSetId()))
@@ -975,8 +1043,7 @@
       throws PatchListNotAvailableException, GpgException, OrmException,
       IOException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    try (Repository repo =
-        repoManager.openRepository(ctl.getProject().getNameKey())) {
+    try (Repository repo = openRepoIfNecessary(ctl)) {
       RevisionInfo rev = toRevisionInfo(
           ctl, changeDataFactory.create(db.get(), ctl), in, repo, true);
       accountLoader.fill();
@@ -985,7 +1052,7 @@
   }
 
   private RevisionInfo toRevisionInfo(ChangeControl ctl, ChangeData cd,
-      PatchSet in, Repository repo, boolean fillCommit)
+      PatchSet in, @Nullable Repository repo, boolean fillCommit)
       throws PatchListNotAvailableException, GpgException, OrmException,
       IOException {
     Change c = ctl.getChange();
@@ -1022,6 +1089,7 @@
     if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
       out.files = fileInfoJson.toFileInfoMap(c, in);
       out.files.remove(Patch.COMMIT_MSG);
+      out.files.remove(Patch.MERGE_LIST);
     }
 
     if ((out.isCurrent || (out.draft != null && out.draft))
@@ -1143,14 +1211,6 @@
 
   @AutoValue
   abstract static class LabelWithStatus {
-    private static final Function<LabelWithStatus, LabelInfo> TO_LABEL_INFO =
-        new Function<LabelWithStatus, LabelInfo>() {
-          @Override
-          public LabelInfo apply(LabelWithStatus in) {
-            return in.label();
-          }
-        };
-
     private static LabelWithStatus create(LabelInfo label,
         SubmitRecord.Label.Status status) {
       return new AutoValue_ChangeJson_LabelWithStatus(label, status);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
index 2302b70..e971eff 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 
 import org.eclipse.jgit.lib.ObjectId;
@@ -31,10 +32,11 @@
  * implementation changes, which might invalidate old entries).
  */
 public interface ChangeKindCache {
-  ChangeKind getChangeKind(ProjectState project, Repository repo,
+  ChangeKind getChangeKind(Project.NameKey project, @Nullable Repository repo,
       ObjectId prior, ObjectId next);
 
   ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch);
 
-  ChangeKind getChangeKind(Repository repo, ChangeData cd, PatchSet patch);
+  ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd,
+      PatchSet patch);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index edc1b12..c0c0492 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -22,17 +22,17 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.Weigher;
 import com.google.common.collect.FluentIterable;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -83,7 +83,6 @@
   public static class NoCache implements ChangeKindCache {
     private final boolean useRecursiveMerge;
     private final ChangeData.Factory changeDataFactory;
-    private final ProjectCache projectCache;
     private final GitRepositoryManager repoManager;
 
 
@@ -91,23 +90,21 @@
     NoCache(
         @GerritServerConfig Config serverConfig,
         ChangeData.Factory changeDataFactory,
-        ProjectCache projectCache,
         GitRepositoryManager repoManager) {
       this.useRecursiveMerge = MergeUtil.useRecursiveMerge(serverConfig);
       this.changeDataFactory = changeDataFactory;
-      this.projectCache = projectCache;
       this.repoManager = repoManager;
     }
 
     @Override
-    public ChangeKind getChangeKind(ProjectState project, Repository repo,
-        ObjectId prior, ObjectId next) {
+    public ChangeKind getChangeKind(Project.NameKey project,
+        @Nullable Repository repo, ObjectId prior, ObjectId next) {
       try {
         Key key = new Key(prior, next, useRecursiveMerge);
-        return new Loader(key, repo).call();
+        return new Loader(key, repoManager, project, repo).call();
       } catch (IOException e) {
         log.warn("Cannot check trivial rebase of new patch set " + next.name()
-            + " in " + project.getProject().getName(), e);
+            + " in " + project, e);
         return ChangeKind.REWORK;
       }
     }
@@ -116,13 +113,13 @@
     public ChangeKind getChangeKind(ReviewDb db, Change change,
         PatchSet patch) {
       return getChangeKindInternal(this, db, change, patch, changeDataFactory,
-          projectCache, repoManager);
+          repoManager);
     }
 
     @Override
-    public ChangeKind getChangeKind(Repository repo, ChangeData cd,
+    public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd,
         PatchSet patch) {
-      return getChangeKindInternal(this, repo, cd, patch, projectCache);
+      return getChangeKindInternal(this, repo, cd, patch);
     }
   }
 
@@ -191,11 +188,16 @@
 
   private static class Loader implements Callable<ChangeKind> {
     private final Key key;
-    private final Repository repo;
+    private final GitRepositoryManager repoManager;
+    private final Project.NameKey projectName;
+    private final Repository alreadyOpenRepo;
 
-    private Loader(Key key, Repository repo) {
+    private Loader(Key key, GitRepositoryManager repoManager,
+        Project.NameKey projectName, @Nullable Repository alreadyOpenRepo) {
       this.key = key;
-      this.repo = repo;
+      this.repoManager = repoManager;
+      this.projectName = projectName;
+      this.alreadyOpenRepo = alreadyOpenRepo;
     }
 
     @Override
@@ -204,6 +206,12 @@
         return ChangeKind.NO_CODE_CHANGE;
       }
 
+      Repository repo = alreadyOpenRepo;
+      boolean close = false;
+      if (repo == null) {
+        repo = repoManager.openRepository(projectName);
+        close = true;
+      }
       try (RevWalk walk = new RevWalk(repo)) {
         RevCommit prior = walk.parseCommit(key.prior);
         walk.parseBody(prior);
@@ -246,6 +254,10 @@
           // it was a rework.
         }
         return ChangeKind.REWORK;
+      } finally {
+        if (close) {
+          repo.close();
+        }
       }
     }
 
@@ -303,7 +315,6 @@
   private final Cache<Key, ChangeKind> cache;
   private final boolean useRecursiveMerge;
   private final ChangeData.Factory changeDataFactory;
-  private final ProjectCache projectCache;
   private final GitRepositoryManager repoManager;
 
   @Inject
@@ -311,24 +322,22 @@
       @GerritServerConfig Config serverConfig,
       @Named(ID_CACHE) Cache<Key, ChangeKind> cache,
       ChangeData.Factory changeDataFactory,
-      ProjectCache projectCache,
       GitRepositoryManager repoManager) {
     this.cache = cache;
     this.useRecursiveMerge = MergeUtil.useRecursiveMerge(serverConfig);
     this.changeDataFactory = changeDataFactory;
-    this.projectCache = projectCache;
     this.repoManager = repoManager;
   }
 
   @Override
-  public ChangeKind getChangeKind(ProjectState project, Repository repo,
-      ObjectId prior, ObjectId next) {
+  public ChangeKind getChangeKind(Project.NameKey project,
+      @Nullable Repository repo, ObjectId prior, ObjectId next) {
     try {
       Key key = new Key(prior, next, useRecursiveMerge);
-      return cache.get(key, new Loader(key, repo));
+      return cache.get(key, new Loader(key, repoManager, project, repo));
     } catch (ExecutionException e) {
       log.warn("Cannot check trivial rebase of new patch set " + next.name()
-          + " in " + project.getProject().getName(), e);
+          + " in " + project, e);
       return ChangeKind.REWORK;
     }
   }
@@ -336,27 +345,25 @@
   @Override
   public ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch) {
     return getChangeKindInternal(this, db, change, patch, changeDataFactory,
-        projectCache, repoManager);
+        repoManager);
   }
 
   @Override
-  public ChangeKind getChangeKind(Repository repo, ChangeData cd,
+  public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd,
       PatchSet patch) {
-    return getChangeKindInternal(this, repo, cd, patch, projectCache);
+    return getChangeKindInternal(this, repo, cd, patch);
   }
 
   private static ChangeKind getChangeKindInternal(
       ChangeKindCache cache,
-      Repository repo,
+      @Nullable Repository repo,
       ChangeData change,
-      PatchSet patch,
-      ProjectCache projectCache) {
+      PatchSet patch) {
     ChangeKind kind = ChangeKind.REWORK;
     // Trivial case: if we're on the first patch, we don't need to use
     // the repository.
     if (patch.getId().get() > 1) {
       try {
-        ProjectState projectState = projectCache.checkedGet(change.project());
         Collection<PatchSet> patchSetCollection = change.patchSets();
         PatchSet priorPs = patch;
         for (PatchSet ps : patchSetCollection) {
@@ -374,11 +381,11 @@
         // and deletes the draft.
         if (priorPs != patch) {
           kind =
-              cache.getChangeKind(projectState, repo,
+              cache.getChangeKind(change.project(), repo,
                   ObjectId.fromString(priorPs.getRevision().get()),
                   ObjectId.fromString(patch.getRevision().get()));
         }
-      } catch (IOException | OrmException e) {
+      } catch (OrmException e) {
         // Do nothing; assume we have a complex change
         log.warn("Unable to get change kind for patchSet " + patch.getPatchSetId() +
             "of change " + change.getId(), e);
@@ -393,7 +400,6 @@
       Change change,
       PatchSet patch,
       ChangeData.Factory changeDataFactory,
-      ProjectCache projectCache,
       GitRepositoryManager repoManager) {
     // TODO - dborowitz: add NEW_CHANGE type for default.
     ChangeKind kind = ChangeKind.REWORK;
@@ -402,8 +408,7 @@
     if (patch.getId().get() > 1) {
       try (Repository repo = repoManager.openRepository(change.getProject())) {
         kind = getChangeKindInternal(cache, repo,
-            changeDataFactory.create(db, change), patch,
-            projectCache);
+            changeDataFactory.create(db, change), patch);
       } catch (IOException e) {
         // Do nothing; assume we have a complex change
         log.warn("Unable to get change kind for patchSet " + patch.getPatchSetId() +
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java
index 7069e6d..fc3e70a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java
@@ -15,12 +15,13 @@
 package com.google.gerrit.server.change;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Optional;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 
+import java.util.Optional;
+
 @AutoValue
 public abstract class ChangeTriplet {
   public static String format(Change change) {
@@ -44,7 +45,7 @@
     int t2 = triplet.lastIndexOf('~');
     int t1 = triplet.lastIndexOf('~', t2 - 1);
     if (t1 < 0 || t2 < 0) {
-      return Optional.absent();
+      return Optional.empty();
     }
 
     String project = Url.decode(triplet.substring(0, t1));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
index 1a063f4..b5eb193 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
@@ -60,10 +60,12 @@
   public ChangeInfo apply(RevisionResource revision, CherryPickInput input)
       throws OrmException, IOException, UpdateException, RestApiException {
     final ChangeControl control = revision.getControl();
+    int parent = input.parent == null ? 1 : input.parent;
 
     if (input.message == null || input.message.trim().isEmpty()) {
       throw new BadRequestException("message must be non-empty");
-    } else if (input.destination == null || input.destination.trim().isEmpty()) {
+    } else if (input.destination == null
+        || input.destination.trim().isEmpty()) {
       throw new BadRequestException("destination must be non-empty");
     }
 
@@ -91,7 +93,7 @@
       Change.Id cherryPickedChangeId =
           cherryPickChange.cherryPick(revision.getChange(),
               revision.getPatchSet(), input.message, refName,
-              refControl);
+              refControl, parent);
       return json.create(ChangeJson.NO_OPTIONS).format(revision.getProject(),
           cherryPickedChangeId);
     } catch (InvalidChangeOperationException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
index db18ba2..21ba93b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -113,8 +114,8 @@
   }
 
   public Change.Id cherryPick(Change change, PatchSet patch,
-      final String message, final String ref,
-      final RefControl refControl) throws NoSuchChangeException,
+      final String message, final String ref, final RefControl refControl,
+      int parent) throws NoSuchChangeException,
       OrmException, MissingObjectException,
       IncorrectObjectTypeException, IOException,
       InvalidChangeOperationException, IntegrationException, UpdateException,
@@ -146,6 +147,13 @@
       CodeReviewCommit commitToCherryPick =
           revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
 
+      if (parent <= 0 || parent > commitToCherryPick.getParentCount()) {
+        throw new InvalidChangeOperationException(String.format(
+            "Cherry Pick: Parent %s does not exist. Please specify a parent in"
+                + " range [1, %s].",
+            parent, commitToCherryPick.getParentCount()));
+      }
+
       Timestamp now = TimeUtil.nowTs();
       PersonIdent committerIdent =
           identifiedUser.newCommitterIdent(now, serverTimeZone);
@@ -159,10 +167,12 @@
 
       CodeReviewCommit cherryPickCommit;
       try {
-        ProjectState projectState = refControl.getProjectControl().getProjectState();
-        cherryPickCommit =
-            mergeUtilFactory.create(projectState).createCherryPickFromCommit(git, oi, mergeTip,
-                commitToCherryPick, committerIdent, commitMessage, revWalk);
+        ProjectState projectState = refControl.getProjectControl()
+            .getProjectState();
+        cherryPickCommit = mergeUtilFactory.create(projectState)
+            .createCherryPickFromCommit(git, oi, mergeTip,
+                commitToCherryPick, committerIdent, commitMessage, revWalk,
+                parent - 1);
 
         Change.Key changeKey;
         final List<String> idList = cherryPickCommit.getFooterLines(
@@ -236,7 +246,7 @@
     bu.addOp(destChange.getId(), inserter
         .setMessage("Uploaded patch set " + newPatchSetId.get() + ".")
         .setDraft(current.isDraft())
-        .setSendMail(false));
+        .setNotify(NotifyHandling.NONE));
     return destChange.getId();
   }
 
@@ -271,10 +281,6 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) throws OrmException {
-      ChangeMessage changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(
-              ctx.getChange().getId(), ChangeUtil.messageUUID(ctx.getDb())),
-              ctx.getAccountId(), ctx.getWhen(), psId);
       StringBuilder sb = new StringBuilder("Patch Set ")
           .append(psId.get())
           .append(": Cherry Picked")
@@ -283,8 +289,9 @@
           .append(destBranch)
           .append(" as commit ")
           .append(cherryPickCommit.name());
-      changeMessage.setMessage(sb.toString());
-
+      ChangeMessage changeMessage = ChangeMessagesUtil.newMessage(
+          ctx.getDb(), psId, ctx.getUser(), ctx.getWhen(), sb.toString(),
+          ChangeMessagesUtil.TAG_CHERRY_PICK_CHANGE);
       cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
       return true;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
index d1ce453..54f73bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.PatchLineCommentsUtil.COMMENT_INFO_ORDER;
+import static com.google.gerrit.server.CommentsUtil.COMMENT_INFO_ORDER;
 
-import com.google.common.base.Function;
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -57,104 +57,135 @@
     return this;
   }
 
-  CommentInfo format(PatchLineComment c) throws OrmException {
-    AccountLoader loader = null;
-    if (fillAccounts) {
-      loader = accountLoaderFactory.create(true);
-    }
-    CommentInfo commentInfo = toCommentInfo(c, loader);
-    if (fillAccounts) {
-      loader.fill();
-    }
-    return commentInfo;
+  public CommentFormatter newCommentFormatter() {
+    return new CommentFormatter();
   }
 
-  Map<String, List<CommentInfo>> format(Iterable<PatchLineComment> l)
-      throws OrmException {
-    Map<String, List<CommentInfo>> out = new TreeMap<>();
-    AccountLoader accountLoader = fillAccounts
-        ? accountLoaderFactory.create(true)
-        : null;
+  public RobotCommentFormatter newRobotCommentFormatter() {
+    return new RobotCommentFormatter();
+  }
 
-    for (PatchLineComment c : l) {
-      CommentInfo o = toCommentInfo(c, accountLoader);
-      List<CommentInfo> list = out.get(o.path);
-      if (list == null) {
-        list = new ArrayList<>();
-        out.put(o.path, list);
+  private abstract class BaseCommentFormatter<F extends Comment,
+      T extends CommentInfo> {
+    public T format(F comment) throws OrmException {
+      AccountLoader loader =
+          fillAccounts ? accountLoaderFactory.create(true) : null;
+      T info = toInfo(comment, loader);
+      if (loader != null) {
+        loader.fill();
       }
-      o.path = null;
-      list.add(o);
+      return info;
     }
 
-    for (List<CommentInfo> list : out.values()) {
-      Collections.sort(list, COMMENT_INFO_ORDER);
+    public Map<String, List<T>> format(Iterable<F> comments)
+        throws OrmException {
+      AccountLoader loader =
+          fillAccounts ? accountLoaderFactory.create(true) : null;
+
+      Map<String, List<T>> out = new TreeMap<>();
+
+      for (F c : comments) {
+        T o = toInfo(c, loader);
+        List<T> list = out.get(o.path);
+        if (list == null) {
+          list = new ArrayList<>();
+          out.put(o.path, list);
+        }
+        o.path = null;
+        list.add(o);
+      }
+
+      for (List<T> list : out.values()) {
+        Collections.sort(list, COMMENT_INFO_ORDER);
+      }
+
+      if (loader != null) {
+        loader.fill();
+      }
+      return out;
     }
 
-    if (accountLoader != null) {
-      accountLoader.fill();
+    public List<T> formatAsList(Iterable<F> comments) throws OrmException {
+      AccountLoader loader =
+          fillAccounts ? accountLoaderFactory.create(true) : null;
+
+      List<T> out = FluentIterable.from(comments)
+          .transform(c -> toInfo(c, loader))
+          .toSortedList(COMMENT_INFO_ORDER);
+
+      if (loader != null) {
+        loader.fill();
+      }
+      return out;
     }
 
-    return out;
-  }
+    protected abstract T toInfo(F comment, AccountLoader loader);
 
-  List<CommentInfo> formatAsList(Iterable<PatchLineComment> l)
-      throws OrmException {
-    final AccountLoader accountLoader = fillAccounts
-        ? accountLoaderFactory.create(true)
-        : null;
-    List<CommentInfo> out = FluentIterable
-        .from(l)
-        .transform(new Function<PatchLineComment, CommentInfo>() {
-          @Override
-          public CommentInfo apply(PatchLineComment c) {
-            return toCommentInfo(c, accountLoader);
-          }
-        }).toSortedList(COMMENT_INFO_ORDER);
-
-    if (accountLoader != null) {
-      accountLoader.fill();
-    }
-
-    return out;
-  }
-
-  private CommentInfo toCommentInfo(PatchLineComment c, AccountLoader loader) {
-    CommentInfo r = new CommentInfo();
-    if (fillPatchSet) {
-      r.patchSet = c.getKey().getParentKey().getParentKey().get();
-    }
-    r.id = Url.encode(c.getKey().get());
-    r.path = c.getKey().getParentKey().getFileName();
-    if (c.getSide() <= 0) {
-      r.side = Side.PARENT;
-      if (c.getSide() < 0) {
-        r.parent = -c.getSide();
+    protected void fillCommentInfo(Comment c, CommentInfo r,
+        AccountLoader loader) {
+      if (fillPatchSet) {
+        r.patchSet = c.key.patchSetId;
+      }
+      r.id = Url.encode(c.key.uuid);
+      r.path = c.key.filename;
+      if (c.side <= 0) {
+        r.side = Side.PARENT;
+        if (c.side < 0) {
+          r.parent = -c.side;
+        }
+      }
+      if (c.lineNbr > 0) {
+        r.line = c.lineNbr;
+      }
+      r.inReplyTo = Url.encode(c.parentUuid);
+      r.message = Strings.emptyToNull(c.message);
+      r.updated = c.writtenOn;
+      r.range = toRange(c.range);
+      r.tag = c.tag;
+      if (loader != null) {
+        r.author = loader.get(c.author.getId());
       }
     }
-    if (c.getLine() > 0) {
-      r.line = c.getLine();
+
+    private Range toRange(Comment.Range commentRange) {
+      Range range = null;
+      if (commentRange != null) {
+        range = new Range();
+        range.startLine = commentRange.startLine;
+        range.startCharacter = commentRange.startChar;
+        range.endLine = commentRange.endLine;
+        range.endCharacter = commentRange.endChar;
+      }
+      return range;
     }
-    r.inReplyTo = Url.encode(c.getParentUuid());
-    r.message = Strings.emptyToNull(c.getMessage());
-    r.updated = c.getWrittenOn();
-    r.range = toRange(c.getRange());
-    r.tag = c.getTag();
-    if (loader != null) {
-      r.author = loader.get(c.getAuthor());
-    }
-    return r;
   }
 
-  private Range toRange(CommentRange commentRange) {
-    Range range = null;
-    if (commentRange != null) {
-      range = new Range();
-      range.startLine = commentRange.getStartLine();
-      range.startCharacter = commentRange.getStartCharacter();
-      range.endLine = commentRange.getEndLine();
-      range.endCharacter = commentRange.getEndCharacter();
+  class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
+    @Override
+    protected CommentInfo toInfo(Comment c, AccountLoader loader) {
+      CommentInfo ci = new CommentInfo();
+      fillCommentInfo(c, ci, loader);
+      return ci;
     }
-    return range;
+
+    private CommentFormatter() {
+    }
+  }
+
+  class RobotCommentFormatter
+      extends BaseCommentFormatter<RobotComment, RobotCommentInfo> {
+    @Override
+    protected RobotCommentInfo toInfo(RobotComment c, AccountLoader loader) {
+      RobotCommentInfo rci = new RobotCommentInfo();
+      rci.robotId = c.robotId;
+      rci.robotRunId = c.robotRunId;
+      rci.url = c.url;
+      rci.properties = c.properties;
+      fillCommentInfo(c, rci, loader);
+      return rci;
+    }
+
+    private RobotCommentFormatter() {
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
index c535e9e..40c8515 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.inject.TypeLiteral;
 
@@ -26,9 +26,9 @@
       new TypeLiteral<RestView<CommentResource>>() {};
 
   private final RevisionResource rev;
-  private final PatchLineComment comment;
+  private final Comment comment;
 
-  public CommentResource(RevisionResource rev, PatchLineComment c) {
+  public CommentResource(RevisionResource rev, Comment c) {
     this.rev = rev;
     this.comment = c;
   }
@@ -37,15 +37,15 @@
     return rev.getPatchSet();
   }
 
-  PatchLineComment getComment() {
+  Comment getComment() {
     return comment;
   }
 
   String getId() {
-    return comment.getKey().get();
+    return comment.key.uuid;
   }
 
   Account.Id getAuthorId() {
-    return comment.getAuthor();
+    return comment.author.getId();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Comments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Comments.java
index 8f78f0e..6ce7dda 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Comments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Comments.java
@@ -19,9 +19,9 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -33,16 +33,16 @@
   private final DynamicMap<RestView<CommentResource>> views;
   private final ListRevisionComments list;
   private final Provider<ReviewDb> dbProvider;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   @Inject
   Comments(DynamicMap<RestView<CommentResource>> views,
       ListRevisionComments list, Provider<ReviewDb> dbProvider,
-      PatchLineCommentsUtil plcUtil) {
+      CommentsUtil commentsUtil) {
     this.views = views;
     this.list = list;
     this.dbProvider = dbProvider;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
   @Override
@@ -61,9 +61,9 @@
     String uuid = id.get();
     ChangeNotes notes = rev.getNotes();
 
-    for (PatchLineComment c : plcUtil.publishedByPatchSet(dbProvider.get(),
+    for (Comment c : commentsUtil.publishedByPatchSet(dbProvider.get(),
         notes, rev.getPatchSet().getId())) {
-      if (uuid.equals(c.getKey().get())) {
+      if (uuid.equals(c.key.uuid)) {
         return new CommentResource(rev, c);
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 287c3ed..20e5b9d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -19,19 +19,17 @@
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
 import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
-import static com.google.gerrit.server.ChangeUtil.TO_PS_ID;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Function;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ProblemInfo.Status;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -254,13 +252,10 @@
 
     Map<String, Ref> refs;
     try {
-    refs = repo.getRefDatabase().exactRef(
-        Lists.transform(all, new Function<PatchSet, String>() {
-          @Override
-          public String apply(PatchSet ps) {
-            return ps.getId().toRefName();
-          }
-        }).toArray(new String[all.size()]));
+      refs = repo.getRefDatabase().exactRef(
+          all.stream()
+              .map(ps -> ps.getId().toRefName())
+              .toArray(String[]::new));
     } catch (IOException e) {
       error("error reading refs", e);
       refs = Collections.emptyMap();
@@ -318,7 +313,7 @@
       if (e.getValue().size() > 1) {
         problem(String.format("Multiple patch sets pointing to %s: %s",
             e.getKey().name(),
-            Collections2.transform(e.getValue(), TO_PS_ID)));
+            Collections2.transform(e.getValue(), PatchSet::getPatchSetId)));
       }
     }
 
@@ -530,7 +525,7 @@
         bu.addOp(ctl.getId(), inserter
             .setValidatePolicy(CommitValidators.Policy.NONE)
             .setFireRevisionCreated(false)
-            .setSendMail(false)
+            .setNotify(NotifyHandling.NONE)
             .setAllowClosed(true)
             .setMessage(
                 "Patch set for merged commit inserted by consistency checker"));
@@ -663,7 +658,7 @@
     public boolean updateChange(ChangeContext ctx)
         throws OrmException, PatchSetInfoNotAvailableException {
       // Delete dangling key references.
-      ReviewDb db = DeleteDraftChangeOp.unwrap(ctx.getDb());
+      ReviewDb db = DeleteChangeOp.unwrap(ctx.getDb());
       accountPatchReviewStore.get().clearReviewed(psId);
       db.changeMessages().delete(
           db.changeMessages().byChange(psId.getParentKey()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
index 7cb2aac..47821f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
@@ -14,8 +14,7 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
-import static com.google.gerrit.server.change.PutDraftComment.side;
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
@@ -27,12 +26,11 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -50,7 +48,7 @@
   private final Provider<ReviewDb> db;
   private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
 
@@ -58,13 +56,13 @@
   CreateDraftComment(Provider<ReviewDb> db,
       BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache) {
     this.db = db;
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
   }
@@ -87,8 +85,8 @@
       Op op = new Op(rsrc.getPatchSet().getId(), in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
-      return Response.created(
-          commentJson.get().setFillAccounts(false).format(op.comment));
+      return Response.created(commentJson.get().setFillAccounts(false)
+          .newCommentFormatter().format(op.comment));
     }
   }
 
@@ -96,7 +94,7 @@
     private final PatchSet.Id psId;
     private final DraftInput in;
 
-    private PatchLineComment comment;
+    private Comment comment;
 
     private Op(PatchSet.Id psId, DraftInput in) {
       this.psId = psId;
@@ -110,23 +108,16 @@
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
-      int line = in.line != null
-          ? in.line
-          : in.range != null ? in.range.endLine : 0;
-      comment = new PatchLineComment(
-          new PatchLineComment.Key(
-              new Patch.Key(ps.getId(), in.path),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          line, ctx.getAccountId(), Url.decode(in.inReplyTo),
-          ctx.getWhen());
-      comment.setSide(side(in));
-      comment.setMessage(in.message.trim());
-      comment.setRange(in.range);
-      comment.setTag(in.tag);
+      comment = commentsUtil.newComment(
+          ctx, in.path, ps.getId(), in.side(), in.message.trim());
+      comment.parentUuid = Url.decode(in.inReplyTo);
+      comment.setLineNbrAndRange(in.line, in.range);
+      comment.tag = in.tag;
       setCommentRevId(
           comment, patchListCache, ctx.getChange(), ps);
-      plcUtil.putComments(
-          ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(comment));
+
+      commentsUtil.putComments(ctx.getDb(), ctx.getUpdate(psId), Status.DRAFT,
+          Collections.singleton(comment));
       ctx.bumpLastUpdatedOn(false);
       return true;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
new file mode 100644
index 0000000..6eb144a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
@@ -0,0 +1,212 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeIdenticalTreeException;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.EnumSet;
+import java.util.TimeZone;
+
+@Singleton
+public class CreateMergePatchSet implements
+    RestModifyView<ChangeResource, MergePatchSetInput> {
+
+  private final Provider<ReviewDb> db;
+  private final GitRepositoryManager gitManager;
+  private final TimeZone serverTimeZone;
+  private final Provider<CurrentUser> user;
+  private final ChangeJson.Factory jsonFactory;
+  private final PatchSetUtil psUtil;
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final PatchSetInserter.Factory patchSetInserterFactory;
+
+  @Inject
+  CreateMergePatchSet(Provider<ReviewDb> db,
+      GitRepositoryManager gitManager,
+      @GerritPersonIdent PersonIdent myIdent,
+      Provider<CurrentUser> user,
+      ChangeJson.Factory json,
+      PatchSetUtil psUtil,
+      MergeUtil.Factory mergeUtilFactory,
+      BatchUpdate.Factory batchUpdateFactory,
+      PatchSetInserter.Factory patchSetInserterFactory) {
+    this.db = db;
+    this.gitManager = gitManager;
+    this.serverTimeZone = myIdent.getTimeZone();
+    this.user = user;
+    this.jsonFactory = json;
+    this.psUtil = psUtil;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.patchSetInserterFactory = patchSetInserterFactory;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ChangeResource req, MergePatchSetInput in)
+      throws NoSuchChangeException, OrmException, IOException,
+      InvalidChangeOperationException, RestApiException, UpdateException {
+    if (in.merge == null) {
+      throw new BadRequestException("merge field is required");
+    }
+
+    MergeInput merge = in.merge;
+    if (Strings.isNullOrEmpty(merge.source)) {
+      throw new BadRequestException("merge.source must be non-empty");
+    }
+
+    ChangeControl ctl = req.getControl();
+    if (!ctl.isVisible(db.get())) {
+      throw new InvalidChangeOperationException(
+          "Base change not found: " + req.getId());
+    }
+    PatchSet ps = psUtil.current(db.get(), ctl.getNotes());
+    if (!ctl.canAddPatchSet(db.get())) {
+      throw new AuthException("cannot add patch set");
+    }
+
+    ProjectControl projectControl = ctl.getProjectControl();
+    Change change = ctl.getChange();
+    Project.NameKey project = change.getProject();
+    Branch.NameKey dest = change.getDest();
+    try (Repository git = gitManager.openRepository(project);
+        ObjectInserter oi = git.newObjectInserter();
+        RevWalk rw = new RevWalk(oi.newReader())) {
+
+      RevCommit sourceCommit =
+          MergeUtil.resolveCommit(git, rw, merge.source);
+      if (!projectControl.canReadCommit(db.get(), git, sourceCommit)) {
+        throw new ResourceNotFoundException(
+            "cannot find source commit: " + merge.source + " to merge.");
+      }
+
+      RevCommit currentPsCommit =
+          rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+
+      Timestamp now = TimeUtil.nowTs();
+      IdentifiedUser me = user.get().asIdentifiedUser();
+      PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+
+      RevCommit newCommit =
+          createMergeCommit(in, projectControl, dest, git, oi, rw,
+              currentPsCommit, sourceCommit, author,
+              ObjectId.fromString(change.getKey().get().substring(1)));
+
+      PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.getId());
+      PatchSetInserter psInserter =
+          patchSetInserterFactory.create(ctl, nextPsId, newCommit);
+      try (BatchUpdate bu = batchUpdateFactory
+          .create(db.get(), project, me, now)) {
+        bu.setRepository(git, rw, oi);
+        bu.addOp(ctl.getId(), psInserter
+            .setMessage("Uploaded patch set " + nextPsId.get() + ".")
+            .setDraft(ps.isDraft())
+            .setNotify(NotifyHandling.NONE));
+        bu.execute();
+      }
+
+      ChangeJson json =
+          jsonFactory.create(EnumSet.of(ListChangesOption.CURRENT_REVISION));
+      return Response.ok(json.format(psInserter.getChange()));
+    }
+  }
+
+  private RevCommit createMergeCommit(MergePatchSetInput in,
+      ProjectControl projectControl, Branch.NameKey dest, Repository git,
+      ObjectInserter oi, RevWalk rw, RevCommit currentPsCommit,
+      RevCommit sourceCommit, PersonIdent author, ObjectId changeId)
+      throws ResourceNotFoundException, MergeIdenticalTreeException,
+      MergeConflictException, IOException {
+
+    ObjectId parentCommit;
+    if (in.inheritParent) {
+      // inherit first parent from previous patch set
+      parentCommit = currentPsCommit.getParent(0);
+    } else {
+      // get the current branch tip of destination branch
+      Ref destRef = git.getRefDatabase().exactRef(dest.get());
+      if (destRef != null) {
+        parentCommit = destRef.getObjectId();
+      } else {
+        throw new ResourceNotFoundException("cannot find destination branch");
+      }
+    }
+    RevCommit mergeTip = rw.parseCommit(parentCommit);
+
+    String commitMsg;
+    if (Strings.emptyToNull(in.subject) != null) {
+      commitMsg = ChangeIdUtil.insertId(in.subject, changeId);
+    } else {
+      // reuse previous patch set commit message
+      commitMsg = currentPsCommit.getFullMessage();
+    }
+
+    String mergeStrategy = MoreObjects.firstNonNull(
+        Strings.emptyToNull(in.merge.strategy),
+        mergeUtilFactory.create(projectControl.getProjectState())
+            .mergeStrategyName());
+
+    return MergeUtil.createMergeCommit(git, oi, mergeTip, sourceCommit,
+        mergeStrategy, author, commitMsg, rw);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
new file mode 100644
index 0000000..f07ee25
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.account.AccountInfoCacheFactory;
+import com.google.gerrit.server.account.AccountJson;
+import com.google.gerrit.server.change.DeleteAssignee.Input;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.extensions.events.AssigneeChanged;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeleteAssignee implements RestModifyView<ChangeResource, Input> {
+  public static class Input {
+
+  }
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> db;
+  private final AccountInfoCacheFactory.Factory accountInfos;
+  private final AssigneeChanged assigneeChanged;
+  private final String anonymousCowardName;
+
+  @Inject
+  DeleteAssignee(BatchUpdate.Factory batchUpdateFactory,
+      ChangeMessagesUtil cmUtil,
+      Provider<ReviewDb> db,
+      AccountInfoCacheFactory.Factory accountInfosFactory,
+      AssigneeChanged assigneeChanged,
+      @AnonymousCowardName String anonymousCowardName) {
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.cmUtil = cmUtil;
+    this.db = db;
+    this.accountInfos = accountInfosFactory;
+    this.assigneeChanged = assigneeChanged;
+    this.anonymousCowardName = anonymousCowardName;
+  }
+
+  @Override
+  public Response<AccountInfo> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
+        rsrc.getProject(),
+        rsrc.getUser(), TimeUtil.nowTs())) {
+      Op op = new Op();
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+      if (op.getDeletedAssignee() == null) {
+        return Response.none();
+      }
+      return Response.ok(AccountJson.toAccountInfo(op.getDeletedAssignee()));
+    }
+  }
+
+  private class Op extends BatchUpdate.Op {
+    private Change change;
+    private Account deletedAssignee;
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws RestApiException, OrmException{
+      if (!ctx.getControl().canEditAssignee()) {
+        throw new AuthException("Delete Assignee not permitted");
+      }
+      change = ctx.getChange();
+      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+      Account.Id currentAssigneeId = change.getAssignee();
+      if (currentAssigneeId == null) {
+        return false;
+      }
+      deletedAssignee = accountInfos.create().get(currentAssigneeId);
+      // noteDb
+      update.removeAssignee();
+      // reviewDb
+      change.setAssignee(null);
+      addMessage(ctx, update, deletedAssignee);
+      return true;
+    }
+
+    public Account getDeletedAssignee() {
+      return deletedAssignee;
+    }
+
+    private void addMessage(BatchUpdate.ChangeContext ctx,
+        ChangeUpdate update, Account deleted) throws OrmException {
+      ChangeMessage cmsg = ChangeMessagesUtil.newMessage(
+          ctx, "Assignee deleted: " + deleted.getName(anonymousCowardName),
+          ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
+      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+    }
+
+    @Override
+    public void postUpdate(Context ctx) throws OrmException {
+      assigneeChanged.fire(change, ctx.getAccount(), deletedAssignee, ctx.getWhen());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
similarity index 75%
rename from gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChange.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
index a125272..18d7074 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
@@ -22,10 +22,11 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.change.DeleteDraftChange.Input;
+import com.google.gerrit.server.change.DeleteChange.Input;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -34,25 +35,25 @@
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class DeleteDraftChange implements
+public class DeleteChange implements
     RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   public static class Input {
   }
 
   private final Provider<ReviewDb> db;
   private final BatchUpdate.Factory updateFactory;
-  private final Provider<DeleteDraftChangeOp> opProvider;
+  private final Provider<DeleteChangeOp> opProvider;
   private final boolean allowDrafts;
 
   @Inject
-  public DeleteDraftChange(Provider<ReviewDb> db,
+  public DeleteChange(Provider<ReviewDb> db,
       BatchUpdate.Factory updateFactory,
-      Provider<DeleteDraftChangeOp> opProvider,
+      Provider<DeleteChangeOp> opProvider,
       @GerritServerConfig Config cfg) {
     this.db = db;
     this.updateFactory = updateFactory;
     this.opProvider = opProvider;
-    this.allowDrafts = DeleteDraftChangeOp.allowDrafts(cfg);
+    this.allowDrafts = DeleteChangeOp.allowDrafts(cfg);
   }
 
   @Override
@@ -71,14 +72,21 @@
   @Override
   public UiAction.Description getDescription(ChangeResource rsrc) {
     try {
+      Change.Status status = rsrc.getChange().getStatus();
+      ChangeControl changeControl = rsrc.getControl();
+      boolean visible = isActionAllowed(changeControl, status)
+          && changeControl.canDelete(db.get(), status);
       return new UiAction.Description()
         .setLabel("Delete")
-        .setTitle("Delete draft change " + rsrc.getId())
-        .setVisible(allowDrafts
-            && rsrc.getChange().getStatus() == Status.DRAFT
-            && rsrc.getControl().canDeleteDraft(db.get()));
+        .setTitle("Delete change " + rsrc.getId())
+        .setVisible(visible);
     } catch (OrmException e) {
       throw new IllegalStateException(e);
     }
   }
+
+  private boolean isActionAllowed(ChangeControl changeControl,
+      Status status) {
+    return status != Status.DRAFT || allowDrafts || changeControl.isAdmin();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
index 7c1e959..604b615 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -27,6 +26,7 @@
 import com.google.inject.Singleton;
 
 import java.io.IOException;
+import java.util.Optional;
 
 @Singleton
 public class DeleteChangeEdit implements RestModifyView<ChangeResource, Input> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
new file mode 100644
index 0000000..d1f7cac
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -0,0 +1,188 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.BatchUpdateReviewDb;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+
+class DeleteChangeOp extends BatchUpdate.Op {
+  static boolean allowDrafts(Config cfg) {
+    return cfg.getBoolean("change", "allowDrafts", true);
+  }
+
+  static ReviewDb unwrap(ReviewDb db) {
+    // This is special. We want to delete exactly the rows that are present in
+    // the database, even when reading everything else from NoteDb, so we need
+    // to bypass the write-only wrapper.
+    if (db instanceof BatchUpdateReviewDb) {
+      db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+    }
+    return ReviewDbUtil.unwrapDb(db);
+  }
+
+
+  private final PatchSetUtil psUtil;
+  private final StarredChangesUtil starredChangesUtil;
+  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+  private final boolean allowDrafts;
+
+  private Change.Id id;
+
+  @Inject
+  DeleteChangeOp(PatchSetUtil psUtil,
+      StarredChangesUtil starredChangesUtil,
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
+      @GerritServerConfig Config cfg) {
+    this.psUtil = psUtil;
+    this.starredChangesUtil = starredChangesUtil;
+    this.accountPatchReviewStore = accountPatchReviewStore;
+    this.allowDrafts = allowDrafts(cfg);
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException,
+      OrmException, IOException, NoSuchChangeException {
+    checkState(ctx.getOrder() == BatchUpdate.Order.DB_BEFORE_REPO,
+        "must use DeleteChangeOp with DB_BEFORE_REPO");
+    checkState(id == null, "cannot reuse DeleteChangeOp");
+
+    id = ctx.getChange().getId();
+    Collection<PatchSet> patchSets = psUtil.byChange(ctx.getDb(),
+        ctx.getNotes());
+
+    ensureDeletable(ctx, id, patchSets);
+    // Cleaning up is only possible as long as the change and its elements are
+    // still part of the database.
+    cleanUpReferences(ctx, id, patchSets);
+    deleteChangeElementsFromDb(ctx, id);
+
+    ctx.deleteChange();
+    return true;
+  }
+
+  private void ensureDeletable(ChangeContext ctx, Change.Id id,
+      Collection<PatchSet> patchSets) throws ResourceConflictException,
+      MethodNotAllowedException, OrmException, AuthException, IOException {
+    Change.Status status = ctx.getChange().getStatus();
+    if (status == Change.Status.MERGED) {
+      throw new MethodNotAllowedException("Deleting merged change " + id
+          + " is not allowed");
+    }
+    for (PatchSet patchSet : patchSets) {
+      if (isPatchSetMerged(ctx, patchSet)) {
+        throw new ResourceConflictException(String.format(
+            "Cannot delete change %s: patch set %s is already merged",
+            id, patchSet.getPatchSetId()));
+      }
+    }
+
+    if (!ctx.getControl().canDelete(ctx.getDb(), status)) {
+      throw new AuthException("Deleting change " + id + " is not permitted");
+    }
+
+    if (status == Change.Status.DRAFT) {
+      if (!allowDrafts && !ctx.getControl().isAdmin()) {
+        throw new MethodNotAllowedException("Draft workflow is disabled");
+      }
+      for (PatchSet ps : patchSets) {
+        if (!ps.isDraft()) {
+          throw new ResourceConflictException("Cannot delete draft change " + id
+              + ": patch set " + ps.getPatchSetId() + " is not a draft");
+        }
+      }
+    }
+  }
+
+  private boolean isPatchSetMerged(ChangeContext ctx, PatchSet patchSet)
+      throws IOException {
+    Repository repository = ctx.getRepository();
+    Ref destinationRef = repository.exactRef(ctx.getChange().getDest().get());
+    if (destinationRef == null) {
+      return false;
+    }
+
+    RevWalk revWalk = ctx.getRevWalk();
+    ObjectId objectId = ObjectId.fromString(patchSet.getRevision().get());
+    RevCommit revCommit = revWalk.parseCommit(objectId);
+    return IncludedInResolver.includedInOne(repository, revWalk, revCommit,
+        Collections.singletonList(destinationRef));
+  }
+
+  private void deleteChangeElementsFromDb(ChangeContext ctx, Change.Id id)
+      throws OrmException {
+    // Only delete from ReviewDb here; deletion from NoteDb is handled in
+    // BatchUpdate.
+    ReviewDb db = unwrap(ctx.getDb());
+    db.patchComments().delete(db.patchComments().byChange(id));
+    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
+    db.patchSets().delete(db.patchSets().byChange(id));
+    db.changeMessages().delete(db.changeMessages().byChange(id));
+  }
+
+  private void cleanUpReferences(ChangeContext ctx, Change.Id id,
+      Collection<PatchSet> patchSets) throws OrmException,
+      NoSuchChangeException {
+    for (PatchSet ps : patchSets) {
+      accountPatchReviewStore.get().clearReviewed(ps.getId());
+    }
+
+    // Non-atomic operation on Accounts table; not much we can do to make it
+    // atomic.
+    starredChangesUtil.unstarAll(ctx.getChange().getProject(), id);
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws IOException {
+    String prefix = new PatchSet.Id(id, 1).toRefName();
+    prefix = prefix.substring(0, prefix.length() - 1);
+    for (Ref ref
+        : ctx.getRepository().getRefDatabase().getRefs(prefix).values()) {
+      ctx.addRefUpdate(
+          new ReceiveCommand(
+            ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
deleted file mode 100644
index 3ca0e1b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
+++ /dev/null
@@ -1,138 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
-import com.google.gerrit.server.git.BatchUpdateReviewDb;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-import java.io.IOException;
-import java.util.List;
-
-class DeleteDraftChangeOp extends BatchUpdate.Op {
-  static boolean allowDrafts(Config cfg) {
-    return cfg.getBoolean("change", "allowDrafts", true);
-  }
-
-  static ReviewDb unwrap(ReviewDb db) {
-    // This is special. We want to delete exactly the rows that are present in
-    // the database, even when reading everything else from NoteDb, so we need
-    // to bypass the write-only wrapper.
-    if (db instanceof BatchUpdateReviewDb) {
-      db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
-    }
-    return ReviewDbUtil.unwrapDb(db);
-  }
-
-
-  private final PatchSetUtil psUtil;
-  private final StarredChangesUtil starredChangesUtil;
-  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-  private final boolean allowDrafts;
-
-  private Change.Id id;
-
-  @Inject
-  DeleteDraftChangeOp(PatchSetUtil psUtil,
-      StarredChangesUtil starredChangesUtil,
-      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
-      @GerritServerConfig Config cfg) {
-    this.psUtil = psUtil;
-    this.starredChangesUtil = starredChangesUtil;
-    this.accountPatchReviewStore = accountPatchReviewStore;
-    this.allowDrafts = allowDrafts(cfg);
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) throws RestApiException,
-      OrmException, IOException, NoSuchChangeException {
-    checkState(ctx.getOrder() == BatchUpdate.Order.DB_BEFORE_REPO,
-        "must use DeleteDraftChangeOp with DB_BEFORE_REPO");
-    checkState(id == null, "cannot reuse DeleteDraftChangeOp");
-
-    Change change = ctx.getChange();
-    id = change.getId();
-
-    ReviewDb db = unwrap(ctx.getDb());
-    if (change.getStatus() != Change.Status.DRAFT) {
-      throw new ResourceConflictException("Change is not a draft: " + id);
-    }
-    if (!allowDrafts) {
-      throw new MethodNotAllowedException("Draft workflow is disabled");
-    }
-    if (!ctx.getControl().canDeleteDraft(ctx.getDb())) {
-      throw new AuthException("Not permitted to delete this draft change");
-    }
-    List<PatchSet> patchSets = ImmutableList.copyOf(
-        psUtil.byChange(ctx.getDb(), ctx.getNotes()));
-    for (PatchSet ps : patchSets) {
-      if (!ps.isDraft()) {
-        throw new ResourceConflictException("Cannot delete draft change " + id
-            + ": patch set " + ps.getPatchSetId() + " is not a draft");
-      }
-      accountPatchReviewStore.get().clearReviewed(ps.getId());
-    }
-
-    // Only delete from ReviewDb here; deletion from NoteDb is handled in
-    // BatchUpdate.
-    db.patchComments().delete(db.patchComments().byChange(id));
-    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
-    db.patchSets().delete(db.patchSets().byChange(id));
-    db.changeMessages().delete(db.changeMessages().byChange(id));
-
-    // Non-atomic operation on Accounts table; not much we can do to make it
-    // atomic.
-    starredChangesUtil.unstarAll(change.getProject(), id);
-
-    ctx.deleteChange();
-    return true;
-  }
-
-  @Override
-  public void updateRepo(RepoContext ctx) throws IOException {
-    String prefix = new PatchSet.Id(id, 1).toRefName();
-    prefix = prefix.substring(0, prefix.length() - 1);
-    for (Ref ref
-        : ctx.getRepository().getRefDatabase().getRefs(prefix).values()) {
-      ctx.addRefUpdate(
-          new ReceiveCommand(
-            ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
index 56dbfa7..37930dd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
@@ -14,19 +14,18 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DeleteDraftComment.Input;
 import com.google.gerrit.server.git.BatchUpdate;
@@ -39,6 +38,7 @@
 import com.google.inject.Singleton;
 
 import java.util.Collections;
+import java.util.Optional;
 
 @Singleton
 public class DeleteDraftComment
@@ -47,19 +47,19 @@
   }
 
   private final Provider<ReviewDb> db;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final BatchUpdate.Factory updateFactory;
   private final PatchListCache patchListCache;
 
   @Inject
   DeleteDraftComment(Provider<ReviewDb> db,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       BatchUpdate.Factory updateFactory,
       PatchListCache patchListCache) {
     this.db = db;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.updateFactory = updateFactory;
     this.patchListCache = patchListCache;
@@ -71,7 +71,7 @@
     try (BatchUpdate bu = updateFactory.create(
         db.get(), rsrc.getChange().getProject(), rsrc.getControl().getUser(),
         TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getComment().getKey());
+      Op op = new Op(rsrc.getComment().key);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
     }
@@ -79,28 +79,29 @@
   }
 
   private class Op extends BatchUpdate.Op {
-    private final PatchLineComment.Key key;
+    private final Comment.Key key;
 
-    private Op(PatchLineComment.Key key) {
+    private Op(Comment.Key key) {
       this.key = key;
     }
 
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws ResourceNotFoundException, OrmException {
-      Optional<PatchLineComment> maybeComment =
-          plcUtil.get(ctx.getDb(), ctx.getNotes(), key);
+      Optional<Comment> maybeComment =
+          commentsUtil.get(ctx.getDb(), ctx.getNotes(), key);
       if (!maybeComment.isPresent()) {
         return false; // Nothing to do.
       }
-      PatchSet.Id psId = key.getParentKey().getParentKey();
+      PatchSet.Id psId =
+          new PatchSet.Id(ctx.getChange().getId(), key.patchSetId);
       PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
-      PatchLineComment c = maybeComment.get();
+      Comment c = maybeComment.get();
       setCommentRevId(c, patchListCache, ctx.getChange(), ps);
-      plcUtil.deleteComments(
+      commentsUtil.deleteComments(
           ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
       ctx.bumpLastUpdatedOn(false);
       return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
index 1cd8726..e473e39 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
@@ -59,7 +59,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
-  private final Provider<DeleteDraftChangeOp> deleteChangeOpProvider;
+  private final Provider<DeleteChangeOp> deleteChangeOpProvider;
   private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
   private final boolean allowDrafts;
 
@@ -68,7 +68,7 @@
       BatchUpdate.Factory updateFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
-      Provider<DeleteDraftChangeOp> deleteChangeOpProvider,
+      Provider<DeleteChangeOp> deleteChangeOpProvider,
       DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
       @GerritServerConfig Config cfg) {
     this.db = db;
@@ -97,7 +97,7 @@
 
     private Collection<PatchSet> patchSetsBeforeDeletion;
     private PatchSet patchSet;
-    private DeleteDraftChangeOp deleteChangeOp;
+    private DeleteChangeOp deleteChangeOp;
 
     private Op(PatchSet.Id psId) {
       this.psId = psId;
@@ -116,7 +116,7 @@
       if (!allowDrafts) {
         throw new MethodNotAllowedException("Draft workflow is disabled");
       }
-      if (!ctx.getControl().canDeleteDraft(ctx.getDb())) {
+      if (!ctx.getControl().canDelete(ctx.getDb(), Change.Status.DRAFT)) {
         throw new AuthException("Not permitted to delete this draft patch set");
       }
 
@@ -146,8 +146,8 @@
       psUtil.delete(ctx.getDb(), ctx.getUpdate(patchSet.getId()), patchSet);
 
       accountPatchReviewStore.get().clearReviewed(psId);
-      // Use the unwrap from DeleteDraftChangeOp to handle BatchUpdateReviewDb.
-      ReviewDb db = DeleteDraftChangeOp.unwrap(ctx.getDb());
+      // Use the unwrap from DeleteChangeOp to handle BatchUpdateReviewDb.
+      ReviewDb db = DeleteChangeOp.unwrap(ctx.getDb());
       db.changeMessages().delete(db.changeMessages().byPatchSet(psId));
       db.patchComments().delete(db.patchComments().byPatchSet(psId));
       db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(psId));
@@ -195,7 +195,7 @@
             rsrc.getPatchSet().getPatchSetId()))
         .setVisible(allowDrafts
             && rsrc.getPatchSet().isDraft()
-            && rsrc.getControl().canDeleteDraft(db.get())
+            && rsrc.getControl().canDelete(db.get(), Change.Status.DRAFT)
             && psCount > 1);
     } catch (OrmException e) {
       throw new IllegalStateException(e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
index bdefa93..2882a29 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Predicate;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -35,17 +36,15 @@
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.change.DeleteReviewer.Input;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.BatchUpdateReviewDb;
 import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.mail.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gwtorm.server.OrmException;
@@ -62,13 +61,11 @@
 import java.util.Map;
 
 @Singleton
-public class DeleteReviewer implements RestModifyView<ReviewerResource, Input> {
+public class DeleteReviewer
+    implements RestModifyView<ReviewerResource, DeleteReviewerInput> {
   private static final Logger log = LoggerFactory
       .getLogger(DeleteReviewer.class);
 
-  public static class Input {
-  }
-
   private final Provider<ReviewDb> dbProvider;
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
@@ -104,12 +101,19 @@
   }
 
   @Override
-  public Response<?> apply(ReviewerResource rsrc, Input input)
+  public Response<?> apply(ReviewerResource rsrc, DeleteReviewerInput input)
       throws RestApiException, UpdateException {
+    if (input == null) {
+      input = new DeleteReviewerInput();
+    }
+    if (input.notify == null) {
+      input.notify = NotifyHandling.ALL;
+    }
+
     try (BatchUpdate bu = batchUpdateFactory.create(dbProvider.get(),
         rsrc.getChangeResource().getProject(),
         rsrc.getChangeResource().getUser(), TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getReviewerUser().getAccount());
+      Op op = new Op(rsrc.getReviewerUser().getAccount(), input);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
     }
@@ -119,6 +123,7 @@
 
   private class Op extends BatchUpdate.Op {
     private final Account reviewer;
+    private final DeleteReviewerInput input;
     ChangeMessage changeMessage;
     Change currChange;
     PatchSet currPs;
@@ -126,8 +131,9 @@
     Map<String, Short> newApprovals = new HashMap<>();
     Map<String, Short> oldApprovals = new HashMap<>();
 
-    Op(Account reviewerAccount) {
+    Op(Account reviewerAccount, DeleteReviewerInput input) {
       this.reviewer = reviewerAccount;
+      this.input = input;
     }
 
     @Override
@@ -148,57 +154,58 @@
       }
 
       StringBuilder msg = new StringBuilder();
+      msg.append("Removed reviewer " + reviewer.getFullName());
+      StringBuilder removedVotesMsg = new StringBuilder();
+      removedVotesMsg.append(" with the following votes:\n\n");
+      boolean votesRemoved = false;
       for (PatchSetApproval a : approvals(ctx, reviewerId)) {
         if (ctx.getControl().canRemoveReviewer(a)) {
           del.add(a);
           if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
             oldApprovals.put(a.getLabel(), a.getValue());
-            if (msg.length() == 0) {
-              msg.append("Removed reviewer ").append(reviewer.getFullName())
-                  .append(" with the following votes:\n\n");
-            }
-            msg.append("* ").append(a.getLabel())
+            removedVotesMsg.append("* ").append(a.getLabel())
                 .append(formatLabelValue(a.getValue())).append(" by ")
                 .append(userFactory.create(a.getAccountId()).getNameEmail())
                 .append("\n");
+            votesRemoved = true;
           }
         } else {
           throw new AuthException("delete reviewer not permitted");
         }
       }
 
+      if (votesRemoved) {
+        msg.append(removedVotesMsg);
+      } else {
+        msg.append(".");
+      }
+
       ctx.getDb().patchSetApprovals().delete(del);
       ChangeUpdate update = ctx.getUpdate(currPs.getId());
       update.removeReviewer(reviewerId);
 
-      if (msg.length() > 0) {
-        changeMessage = new ChangeMessage(
-            new ChangeMessage.Key(currChange.getId(),
-                ChangeUtil.messageUUID(ctx.getDb())),
-            ctx.getAccountId(), ctx.getWhen(), currPs.getId());
-        changeMessage.setMessage(msg.toString());
-        cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
-      }
+      changeMessage = ChangeMessagesUtil.newMessage(ctx, msg.toString(),
+          ChangeMessagesUtil.TAG_DELETE_REVIEWER);
+      cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
 
       return true;
     }
 
     @Override
     public void postUpdate(Context ctx) {
-      if (changeMessage == null) {
-        return;
+      if (input.notify.compareTo(NotifyHandling.NONE) > 0) {
+        emailReviewers(ctx.getProject(), currChange, del, changeMessage);
       }
-
-      emailReviewers(ctx.getProject(), currChange, del, changeMessage);
       reviewerDeleted.fire(currChange, currPs, reviewer,
           ctx.getAccount(),
           changeMessage.getMessage(),
           newApprovals, oldApprovals,
+          input.notify,
           ctx.getWhen());
     }
 
     private Iterable<PatchSetApproval> approvals(ChangeContext ctx,
-        final Account.Id accountId) throws OrmException {
+        Account.Id accountId) throws OrmException {
       Change.Id changeId = ctx.getNotes().getChangeId();
       Iterable<PatchSetApproval> approvals;
 
@@ -218,13 +225,7 @@
       }
 
       return Iterables.filter(
-          approvals,
-          new Predicate<PatchSetApproval>() {
-            @Override
-            public boolean apply(PatchSetApproval input) {
-              return accountId.equals(input.getAccountId());
-            }
-          });
+          approvals, psa -> accountId.equals(psa.getAccountId()));
     }
 
     private String formatLabelValue(short value) {
@@ -233,30 +234,31 @@
       }
       return Short.toString(value);
     }
-  }
 
-  private void emailReviewers(Project.NameKey projectName, Change change,
-      List<PatchSetApproval> dels, ChangeMessage changeMessage) {
+    private void emailReviewers(Project.NameKey projectName, Change change,
+        List<PatchSetApproval> dels, ChangeMessage changeMessage) {
 
-    // The user knows they removed themselves, don't bother emailing them.
-    List<Account.Id> toMail = Lists.newArrayListWithCapacity(dels.size());
-    Account.Id userId = user.get().getAccountId();
-    for (PatchSetApproval psa : dels) {
-      if (!psa.getAccountId().equals(userId)) {
-        toMail.add(psa.getAccountId());
+      // The user knows they removed themselves, don't bother emailing them.
+      List<Account.Id> toMail = Lists.newArrayListWithCapacity(dels.size());
+      Account.Id userId = user.get().getAccountId();
+      for (PatchSetApproval psa : dels) {
+        if (!psa.getAccountId().equals(userId)) {
+          toMail.add(psa.getAccountId());
+        }
       }
-    }
-    if (!toMail.isEmpty()) {
-      try {
-        DeleteReviewerSender cm =
-            deleteReviewerSenderFactory.create(projectName, change.getId());
-        cm.setFrom(userId);
-        cm.addReviewers(toMail);
-        cm.setChangeMessage(changeMessage.getMessage(),
-            changeMessage.getWrittenOn());
-        cm.send();
-      } catch (Exception err) {
-        log.error("Cannot email update for change " + change.getId(), err);
+      if (!toMail.isEmpty()) {
+        try {
+          DeleteReviewerSender cm =
+              deleteReviewerSenderFactory.create(projectName, change.getId());
+          cm.setFrom(userId);
+          cm.addReviewers(toMail);
+          cm.setChangeMessage(changeMessage.getMessage(),
+              changeMessage.getWrittenOn());
+          cm.setNotify(input.notify);
+          cm.send();
+        } catch (Exception err) {
+          log.error("Cannot email update for change " + change.getId(), err);
+        }
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
index f1bdba5..aab40e0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -28,12 +29,12 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.VoteDeleted;
@@ -41,9 +42,10 @@
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.mail.DeleteVoteSender;
-import com.google.gerrit.server.mail.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -137,64 +139,63 @@
       PatchSet.Id psId = change.currentPatchSetId();
       ps = psUtil.current(db.get(), ctl.getNotes());
 
-      PatchSetApproval psa = null;
-      StringBuilder msg = new StringBuilder();
-
-      // get all of the current approvals
+      boolean found = false;
       LabelTypes labelTypes = ctx.getControl().getLabelTypes();
-      Map<String, Short> currentApprovals = new HashMap<>();
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        currentApprovals.put(lt.getName(), (short) 0);
-        for (PatchSetApproval a : approvalsUtil.byPatchSetUser(
-            ctx.getDb(), ctl, psId, accountId)) {
-          if (lt.getLabelId().equals(a.getLabelId())) {
-            currentApprovals.put(lt.getName(), a.getValue());
-          }
-        }
-      }
-      // removing votes so we need to determine the new set of approval scores
-      newApprovals.putAll(currentApprovals);
+
       for (PatchSetApproval a : approvalsUtil.byPatchSetUser(
-            ctx.getDb(), ctl, psId, accountId)) {
-        if (ctl.canRemoveReviewer(a)) {
-          if (a.getLabel().equals(label)) {
-            // set the approval to 0 if vote is being removed
-            newApprovals.put(a.getLabel(), (short) 0);
-            // set old value only if the vote changed
-            oldApprovals.put(a.getLabel(), a.getValue());
-            msg.append("Removed ")
-                .append(a.getLabel()).append(formatLabelValue(a.getValue()))
-                .append(" by ").append(userFactory.create(a.getAccountId())
-                    .getNameEmail())
-                .append("\n");
-            psa = a;
-            a.setValue((short)0);
-            ctx.getUpdate(psId).removeApprovalFor(a.getAccountId(), label);
-            break;
-          }
-        } else {
+          ctx.getDb(), ctl, psId, accountId)) {
+        if (labelTypes.byLabel(a.getLabelId()) == null) {
+          continue; // Ignore undefined labels.
+        } else if (!a.getLabel().equals(label)) {
+          // Populate map for non-matching labels, needed by VoteDeleted.
+          newApprovals.put(a.getLabel(), a.getValue());
+          continue;
+        } else if (!ctl.canRemoveReviewer(a)) {
           throw new AuthException("delete vote not permitted");
         }
+        // Set the approval to 0 if vote is being removed.
+        newApprovals.put(a.getLabel(), (short) 0);
+        found = true;
+
+        // Set old value, as required by VoteDeleted.
+        oldApprovals.put(a.getLabel(), a.getValue());
+        break;
       }
-      if (psa == null) {
+      if (!found) {
         throw new ResourceNotFoundException();
       }
-      ctx.getDb().patchSetApprovals().update(Collections.singleton(psa));
 
-      if (msg.length() > 0) {
-        changeMessage =
-            new ChangeMessage(new ChangeMessage.Key(change.getId(),
-                ChangeUtil.messageUUID(ctx.getDb())),
-                ctx.getAccountId(),
-                ctx.getWhen(),
-                change.currentPatchSetId());
-        changeMessage.setMessage(msg.toString());
-        cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId),
-            changeMessage);
-      }
+      ctx.getUpdate(psId).removeApprovalFor(accountId, label);
+      ctx.getDb().patchSetApprovals().upsert(
+          Collections.singleton(deletedApproval(ctx)));
+
+      StringBuilder msg = new StringBuilder();
+      msg.append("Removed ");
+      LabelVote.appendTo(msg, label, checkNotNull(oldApprovals.get(label)));
+      msg.append(" by ")
+          .append(userFactory.create(accountId).getNameEmail())
+          .append("\n");
+      changeMessage = ChangeMessagesUtil.newMessage(ctx, msg.toString(),
+          ChangeMessagesUtil.TAG_DELETE_VOTE);
+      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId),
+          changeMessage);
+
       return true;
     }
 
+    private PatchSetApproval deletedApproval(ChangeContext ctx) {
+      // Set the effective user to the account we're trying to remove, and don't
+      // set the real user; this preserves the calling user as the NoteDb
+      // committer.
+      return new PatchSetApproval(
+          new PatchSetApproval.Key(
+              ps.getId(),
+              accountId,
+              new LabelId(label)),
+          (short) 0,
+          ctx.getWhen());
+    }
+
     @Override
     public void postUpdate(Context ctx) {
       if (changeMessage == null) {
@@ -220,11 +221,4 @@
           user.getAccount(), ctx.getWhen());
     }
   }
-
-  private static String formatLabelValue(short value) {
-    if (value > 0) {
-      return "+" + value;
-    }
-    return Short.toString(value);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java
index 3dc0c78..781216c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
@@ -28,9 +28,9 @@
       new TypeLiteral<RestView<DraftCommentResource>>() {};
 
   private final RevisionResource rev;
-  private final PatchLineComment comment;
+  private final Comment comment;
 
-  public DraftCommentResource(RevisionResource rev, PatchLineComment c) {
+  public DraftCommentResource(RevisionResource rev, Comment c) {
     this.rev = rev;
     this.comment = c;
   }
@@ -47,12 +47,12 @@
     return rev.getPatchSet();
   }
 
-  PatchLineComment getComment() {
+  Comment getComment() {
     return comment;
   }
 
   String getId() {
-    return comment.getKey().get();
+    return comment.key.uuid;
   }
 
   Account.Id getAuthorId() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftComments.java
index acb50ac..fe82d66 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftComments.java
@@ -20,10 +20,10 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -35,19 +35,19 @@
   private final Provider<CurrentUser> user;
   private final ListRevisionDrafts list;
   private final Provider<ReviewDb> dbProvider;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   @Inject
   DraftComments(DynamicMap<RestView<DraftCommentResource>> views,
       Provider<CurrentUser> user,
       ListRevisionDrafts list,
       Provider<ReviewDb> dbProvider,
-      PatchLineCommentsUtil plcUtil) {
+      CommentsUtil commentsUtil) {
     this.views = views;
     this.user = user;
     this.list = list;
     this.dbProvider = dbProvider;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
   @Override
@@ -66,9 +66,9 @@
       throws ResourceNotFoundException, OrmException, AuthException {
     checkIdentifiedUser();
     String uuid = id.get();
-    for (PatchLineComment c : plcUtil.draftByPatchSetAuthor(dbProvider.get(),
+    for (Comment c : commentsUtil.draftByPatchSetAuthor(dbProvider.get(),
         rev.getPatchSet().getId(), rev.getAccountId(), rev.getNotes())) {
-      if (uuid.equals(c.getKey().get())) {
+      if (uuid.equals(c.key.uuid)) {
         return new DraftCommentResource(rev, c);
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
index 390f416..bffb61b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.PatchLineCommentsUtil.PLC_ORDER;
+import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.SendEmailExecutor;
-import com.google.gerrit.server.mail.CommentSender;
+import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.util.RequestContext;
@@ -52,7 +52,7 @@
         PatchSet patchSet,
         IdentifiedUser user,
         ChangeMessage message,
-        List<PatchLineComment> comments);
+        List<Comment> comments);
   }
 
   private final ExecutorService sendEmailsExecutor;
@@ -66,7 +66,7 @@
   private final PatchSet patchSet;
   private final IdentifiedUser user;
   private final ChangeMessage message;
-  private List<PatchLineComment> comments;
+  private final List<Comment> comments;
   private ReviewDb db;
 
   @Inject
@@ -81,7 +81,7 @@
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
       @Assisted ChangeMessage message,
-      @Assisted List<PatchLineComment> comments) {
+      @Assisted List<Comment> comments) {
     this.sendEmailsExecutor = executor;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.commentSenderFactory = commentSenderFactory;
@@ -92,7 +92,7 @@
     this.patchSet = patchSet;
     this.user = user;
     this.message = message;
-    this.comments = PLC_ORDER.sortedCopy(comments);
+    this.comments = COMMENT_ORDER.sortedCopy(comments);
   }
 
   void sendAsync() {
@@ -110,7 +110,7 @@
       cm.setPatchSet(patchSet,
           patchSetInfoFactory.get(notes.getProjectName(), patchSet));
       cm.setChangeMessage(message.getMessage(), message.getWrittenOn());
-      cm.setPatchLineComments(comments);
+      cm.setComments(comments);
       cm.setNotify(notify);
       cm.send();
     } catch (Exception e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
index d145ddf..d617a70 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -54,6 +54,7 @@
 @Singleton
 public class FileContentUtil {
   public static final String TEXT_X_GERRIT_COMMIT_MESSAGE = "text/x-gerrit-commit-message";
+  public static final String TEXT_X_GERRIT_MERGE_LIST = "text/x-gerrit-merge-list";
   private static final String X_GIT_SYMLINK = "x-git/symlink";
   private static final String X_GIT_GITLINK = "x-git/gitlink";
   private static final int MAX_SIZE = 5 << 20;
@@ -264,6 +265,9 @@
         if (Patch.COMMIT_MSG.equals(path)) {
           return TEXT_X_GERRIT_COMMIT_MESSAGE;
         }
+        if (Patch.MERGE_LIST.equals(path)) {
+          return TEXT_X_GERRIT_MERGE_LIST;
+        }
         if (project != null) {
           for (ProjectState p : project.tree()) {
             String t = p.getConfig().getMimeTypes().getMimeType(path);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
index e0591f4..8e55df5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.util.GitUtil.getParent;
-
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.FileInfo;
@@ -23,7 +21,6 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -32,24 +29,18 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
 import java.util.Map;
 import java.util.TreeMap;
 
 @Singleton
 public class FileInfoJson {
   private final PatchListCache patchListCache;
-  private final GitRepositoryManager repoManager;
 
   @Inject
   FileInfoJson(
-      PatchListCache patchListCache,
-      GitRepositoryManager repoManager) {
-    this.repoManager = repoManager;
+      PatchListCache patchListCache) {
     this.patchListCache = patchListCache;
   }
 
@@ -64,24 +55,19 @@
         ? null
         : ObjectId.fromString(base.getRevision().get());
     ObjectId b = ObjectId.fromString(revision.get());
-    return toFileInfoMap(change, a, b);
+    return toFileInfoMap(change, new PatchListKey(a, b, Whitespace.IGNORE_NONE));
   }
 
   Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
-      throws RepositoryNotFoundException, IOException,
-          PatchListNotAvailableException {
+      throws PatchListNotAvailableException {
     ObjectId b = ObjectId.fromString(revision.get());
-    ObjectId a;
-    try (Repository git = repoManager.openRepository(change.getProject())) {
-      a = getParent(git, b, parent);
-    }
-    return toFileInfoMap(change, a, b);
+    return toFileInfoMap(change,
+        PatchListKey.againstParentNum(parent + 1, b, Whitespace.IGNORE_NONE));
   }
 
   private Map<String, FileInfo> toFileInfoMap(Change change,
-      ObjectId a, ObjectId b) throws PatchListNotAvailableException {
-    PatchList list = patchListCache.get(
-        new PatchListKey(a, b, Whitespace.IGNORE_NONE), change.getProject());
+      PatchListKey key) throws PatchListNotAvailableException {
+    PatchList list = patchListCache.get(key, change.getProject());
 
     Map<String, FileInfo> files = new TreeMap<>();
     for (PatchListEntry e : list.getPatches()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
index 35dbec1..c077bbb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
@@ -138,9 +138,10 @@
     }
 
     @Override
-    public Response<?> apply(RevisionResource resource) throws AuthException,
-        BadRequestException, ResourceNotFoundException, OrmException,
-        RepositoryNotFoundException, IOException {
+    public Response<?> apply(RevisionResource resource)
+        throws AuthException, BadRequestException, ResourceNotFoundException,
+        OrmException, RepositoryNotFoundException, IOException,
+        PatchListNotAvailableException {
       checkOptions();
       if (reviewed) {
         return Response.ok(reviewed(resource));
@@ -149,26 +150,22 @@
       }
 
       Response<Map<String, FileInfo>> r;
-      try {
-        if (base != null) {
-          RevisionResource baseResource = revisions.parse(
-              resource.getChangeResource(), IdString.fromDecoded(base));
-          r = Response.ok(fileInfoJson.toFileInfoMap(
-              resource.getChange(),
-              resource.getPatchSet().getRevision(),
-              baseResource.getPatchSet()));
-        } else if (parentNum > 0) {
-          r = Response.ok(fileInfoJson.toFileInfoMap(
-              resource.getChange(),
-              resource.getPatchSet().getRevision(),
-              parentNum - 1));
-        } else {
-          r = Response.ok(fileInfoJson.toFileInfoMap(
-              resource.getChange(),
-              resource.getPatchSet()));
-        }
-      } catch (PatchListNotAvailableException e) {
-        throw new ResourceNotFoundException(e.getMessage());
+      if (base != null) {
+        RevisionResource baseResource = revisions.parse(
+            resource.getChangeResource(), IdString.fromDecoded(base));
+        r = Response.ok(fileInfoJson.toFileInfoMap(
+            resource.getChange(),
+            resource.getPatchSet().getRevision(),
+            baseResource.getPatchSet()));
+      } else if (parentNum > 0) {
+        r = Response.ok(fileInfoJson.toFileInfoMap(
+            resource.getChange(),
+            resource.getPatchSet().getRevision(),
+            parentNum - 1));
+      } else {
+        r = Response.ok(fileInfoJson.toFileInfoMap(
+            resource.getChange(),
+            resource.getPatchSet()));
       }
 
       if (resource.isCacheable()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java
index a2fd004..e99eb87 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java
@@ -14,18 +14,13 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Predicate;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
 
 import org.eclipse.jgit.api.ArchiveCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
@@ -37,48 +32,8 @@
 
 import java.io.IOException;
 import java.io.OutputStream;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Set;
 
 public class GetArchive implements RestReadView<RevisionResource> {
-  @Singleton
-  public static class AllowedFormats {
-    final ImmutableMap<String, ArchiveFormat> extensions;
-    final Set<ArchiveFormat> allowed;
-
-    @Inject
-    AllowedFormats(DownloadConfig cfg) {
-      Map<String, ArchiveFormat> exts = new HashMap<>();
-      for (ArchiveFormat format : cfg.getArchiveFormats()) {
-        for (String ext : format.getSuffixes()) {
-          exts.put(ext, format);
-        }
-        exts.put(format.name().toLowerCase(), format);
-      }
-      extensions = ImmutableMap.copyOf(exts);
-
-      // Zip is not supported because it may be interpreted by a Java plugin as a
-      // valid JAR file, whose code would have access to cookies on the domain.
-      allowed = Sets.filter(
-          cfg.getArchiveFormats(),
-          new Predicate<ArchiveFormat>() {
-            @Override
-            public boolean apply(ArchiveFormat format) {
-              return (format != ArchiveFormat.ZIP);
-            }
-          });
-    }
-
-    public Set<ArchiveFormat> getAllowed() {
-      return allowed;
-    }
-
-    public ImmutableMap<String, ArchiveFormat> getExtensions() {
-      return extensions;
-    }
-  }
-
   private final GitRepositoryManager repoManager;
   private final AllowedFormats allowedFormats;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetAssignee.java
new file mode 100644
index 0000000..5ad259b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetAssignee.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountInfoCacheFactory;
+import com.google.gerrit.server.account.AccountJson;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import java.util.Optional;
+
+@Singleton
+public class GetAssignee implements RestReadView<ChangeResource> {
+  private final AccountInfoCacheFactory.Factory accountInfo;
+
+  @Inject
+  GetAssignee(AccountInfoCacheFactory.Factory accountInfo) {
+    this.accountInfo = accountInfo;
+  }
+
+  @Override
+  public Response<AccountInfo> apply(ChangeResource rsrc) throws OrmException {
+    Optional<Account.Id> assignee =
+        Optional.ofNullable(rsrc.getChange().getAssignee());
+    if (assignee.isPresent()) {
+      Account account = accountInfo.create().get(assignee.get());
+      return Response.ok(AccountJson.toAccountInfo(account));
+    }
+    return Response.none();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java
index d87c7eb..d601737 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java
@@ -33,6 +33,6 @@
 
   @Override
   public CommentInfo apply(CommentResource rsrc) throws OrmException {
-    return commentJson.get().format(rsrc.getComment());
+    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
index 8c9a0ad..e51d37b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
@@ -35,7 +35,7 @@
   private final GitRepositoryManager repoManager;
   private final ChangeJson.Factory json;
 
-  @Option(name = "--links", usage = "Add weblinks")
+  @Option(name = "--links", usage = "Include weblinks")
   private boolean addLinks;
 
   @Inject
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
index 5a546f3..c7044e1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.Text;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -68,6 +70,12 @@
       return BinaryResult.create(msg)
           .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
           .base64();
+    } else if (Patch.MERGE_LIST.equals(path)) {
+      byte[] mergeList = getMergeList(
+          rsrc.getRevision().getChangeResource().getNotes());
+      return BinaryResult.create(mergeList)
+          .setContentType(FileContentUtil.TEXT_X_GERRIT_MERGE_LIST)
+          .base64();
     }
     return fileContentUtil.getContent(
         rsrc.getRevision().getControl().getProjectControl().getProjectState(),
@@ -92,4 +100,22 @@
       throw new NoSuchChangeException(changeId, e);
     }
   }
+
+  private byte[] getMergeList(ChangeNotes notes)
+      throws NoSuchChangeException, OrmException, IOException {
+    Change.Id changeId = notes.getChangeId();
+    PatchSet ps = psUtil.current(db.get(), notes);
+    if (ps == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    try (Repository git = gitManager.openRepository(notes.getProjectName());
+        RevWalk revWalk = new RevWalk(git)) {
+      return Text.forMergeList(ComparisonType.againstAutoMerge(),
+          revWalk.getObjectReader(),
+          ObjectId.fromString(ps.getRevision().get())).getContent();
+    } catch (RepositoryNotFoundException e) {
+      throw new NoSuchChangeException(changeId, e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java
index 22f90c9..a380ce3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java
@@ -33,6 +33,6 @@
 
   @Override
   public CommentInfo apply(DraftCommentResource rsrc) throws OrmException {
-    return commentJson.get().format(rsrc.getComment());
+    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java
new file mode 100644
index 0000000..b15810c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.CacheControl;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.MergeListBuilder;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+public class GetMergeList implements RestReadView<RevisionResource> {
+  private final GitRepositoryManager repoManager;
+  private final ChangeJson.Factory json;
+
+  @Option(name = "--parent", usage = "Uninteresting parent (1-based, default = 1)")
+  private int uninterestingParent = 1;
+
+  @Option(name = "--links", usage = "Include weblinks")
+  private boolean addLinks;
+
+  @Inject
+  GetMergeList(GitRepositoryManager repoManager,
+      ChangeJson.Factory json) {
+    this.repoManager = repoManager;
+    this.json = json;
+  }
+
+  public void setUninterestingParent(int uninterestingParent) {
+    this.uninterestingParent = uninterestingParent;
+  }
+
+  public void setAddLinks(boolean addLinks) {
+    this.addLinks = addLinks;
+  }
+
+  @Override
+  public Response<List<CommitInfo>> apply(RevisionResource rsrc)
+      throws BadRequestException, IOException {
+    Project.NameKey p = rsrc.getChange().getProject();
+    try (Repository repo = repoManager.openRepository(p);
+        RevWalk rw = new RevWalk(repo)) {
+      String rev = rsrc.getPatchSet().getRevision().get();
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      rw.parseBody(commit);
+
+      if (uninterestingParent < 1
+          || uninterestingParent > commit.getParentCount()) {
+        throw new BadRequestException("No such parent: " + uninterestingParent);
+      }
+
+      if (commit.getParentCount() < 2) {
+        return createResponse(rsrc, ImmutableList.<CommitInfo> of());
+      }
+
+      List<RevCommit> commits =
+          MergeListBuilder.build(rw, commit, uninterestingParent);
+      List<CommitInfo> result = new ArrayList<>(commits.size());
+      ChangeJson changeJson = json.create(ChangeJson.NO_OPTIONS);
+      for (RevCommit c : commits) {
+        result.add(changeJson.toCommit(rsrc.getControl(), rw, c, addLinks, true));
+      }
+      return createResponse(rsrc, result);
+    }
+  }
+
+  private static Response<List<CommitInfo>> createResponse(
+      RevisionResource rsrc, List<CommitInfo> result) {
+    Response<List<CommitInfo>> r = Response.ok(result);
+    if (rsrc.isCacheable()) {
+      r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+    }
+    return r;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java
new file mode 100644
index 0000000..fa9c0e8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountInfoCacheFactory;
+import com.google.gerrit.server.account.AccountJson;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+public class GetPastAssignees implements RestReadView<ChangeResource> {
+  private final AccountInfoCacheFactory.Factory accountInfos;
+
+  @Inject
+  GetPastAssignees(AccountInfoCacheFactory.Factory accountInfosFactory) {
+    this.accountInfos = accountInfosFactory;
+  }
+
+  @Override
+  public Response<List<AccountInfo>> apply(ChangeResource rsrc)
+      throws OrmException {
+
+    Set<Account.Id> pastAssignees =
+        rsrc.getControl().getNotes().load().getPastAssignees();
+    if (pastAssignees == null) {
+      return Response.ok(Collections.emptyList());
+    }
+    AccountInfoCacheFactory accountInfoFactory = accountInfos.create();
+
+    return Response.ok(pastAssignees.stream()
+        .map(accountInfoFactory::get)
+        .map(AccountJson::toAccountInfo)
+        .collect(toList()));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
index a13e7be..365b204 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -30,6 +31,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilter;
 import org.kohsuke.args4j.Option;
 
 import java.io.IOException;
@@ -43,20 +45,25 @@
 public class GetPatch implements RestReadView<RevisionResource> {
   private final GitRepositoryManager repoManager;
 
+  private final String FILE_NOT_FOUND = "File not found: %s.";
+
   @Option(name = "--zip")
   private boolean zip;
 
   @Option(name = "--download")
   private boolean download;
 
+  @Option(name = "--path")
+  private String path;
+
   @Inject
   GetPatch(GitRepositoryManager repoManager) {
     this.repoManager = repoManager;
   }
 
   @Override
-  public BinaryResult apply(RevisionResource rsrc)
-      throws ResourceConflictException, IOException {
+  public BinaryResult apply(RevisionResource rsrc) throws
+      ResourceConflictException, IOException, ResourceNotFoundException {
     Project.NameKey project = rsrc.getControl().getProject().getNameKey();
     final Repository repo = repoManager.openRepository(project);
     boolean close = true;
@@ -93,9 +100,15 @@
           }
 
           private void format(OutputStream out) throws IOException {
-            out.write(formatEmailHeader(commit).getBytes(UTF_8));
+            // Only add header if no path is specified
+            if (path == null) {
+              out.write(formatEmailHeader(commit).getBytes(UTF_8));
+            }
             try (DiffFormatter fmt = new DiffFormatter(out)) {
               fmt.setRepository(repo);
+              if (path != null) {
+                fmt.setPathFilter(PathFilter.create(path));
+              }
               fmt.format(base.getTree(), commit.getTree());
               fmt.flush();
             }
@@ -108,6 +121,11 @@
           }
         };
 
+        if (path != null && bin.asString().isEmpty()) {
+          throw new ResourceNotFoundException(
+               String.format(FILE_NOT_FOUND, path));
+        }
+
         if (zip) {
           bin.disableGzip()
              .setContentType("application/zip")
@@ -134,6 +152,11 @@
     }
   }
 
+  public GetPatch setPath(String path) {
+    this.path = path;
+    return this;
+  }
+
   private static String formatEmailHeader(RevCommit commit) {
     StringBuilder b = new StringBuilder();
     PersonIdent author = commit.getAuthorIdent();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
index 12e4276..0a7452b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
@@ -92,6 +92,9 @@
     PatchSet basePs = isEdit
         ? rsrc.getEdit().get().getBasePatchSet()
         : rsrc.getPatchSet();
+
+    reloadChangeIfStale(cds, basePs);
+
     for (PatchSetData d : sorter.sort(cds, basePs)) {
       PatchSet ps = d.patchSet();
       RevCommit commit;
@@ -123,6 +126,17 @@
     return result;
   }
 
+  private void reloadChangeIfStale(List<ChangeData> cds, PatchSet wantedPs)
+      throws OrmException {
+    for (ChangeData cd : cds) {
+      if (cd.getId().equals(wantedPs.getId().getParentKey())) {
+        if (cd.patchSet(wantedPs.getId()) == null) {
+          cd.reloadChange();
+        }
+      }
+    }
+  }
+
   public static class RelatedInfo {
     public List<ChangeAndCommit> changes;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
index eae67a2..478d9c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
@@ -41,14 +41,14 @@
   private final ActionJson delegate;
   private final Config config;
   private final Provider<ReviewDb> dbProvider;
-  private final MergeSuperSet mergeSuperSet;
+  private final Provider<MergeSuperSet> mergeSuperSet;
   private final ChangeResource.Factory changeResourceFactory;
 
   @Inject
   GetRevisionActions(
       ActionJson delegate,
       Provider<ReviewDb> dbProvider,
-      MergeSuperSet mergeSuperSet,
+      Provider<MergeSuperSet> mergeSuperSet,
       ChangeResource.Factory changeResourceFactory,
       @GerritServerConfig Config config) {
     this.delegate = delegate;
@@ -72,7 +72,7 @@
       h.putBoolean(Submit.wholeTopicEnabled(config));
       ReviewDb db = dbProvider.get();
       ChangeSet cs =
-          mergeSuperSet.completeChangeSet(db, rsrc.getChange(), user);
+          mergeSuperSet.get().completeChangeSet(db, rsrc.getChange(), user);
       for (ChangeData cd : cs.changes()) {
         changeResourceFactory.create(cd.changeControl()).prepareETag(h, user);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java
new file mode 100644
index 0000000..c10cd2e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetRobotComment implements RestReadView<RobotCommentResource> {
+
+  private final Provider<CommentJson> commentJson;
+
+  @Inject
+  GetRobotComment(Provider<CommentJson> commentJson) {
+    this.commentJson = commentJson;
+  }
+
+  @Override
+  public RobotCommentInfo apply(RobotCommentResource rsrc) throws OrmException {
+    return commentJson.get().newRobotCommentFormatter()
+        .format(rsrc.getComment());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
index 97befa0..32b5ae8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -33,17 +33,17 @@
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   @Inject
   ListChangeComments(Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
-      PatchLineCommentsUtil plcUtil) {
+      CommentsUtil commentsUtil) {
     this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
   @Override
@@ -53,6 +53,7 @@
     return commentJson.get()
         .setFillAccounts(true)
         .setFillPatchSet(true)
-        .format(plcUtil.publishedByChange(db.get(), cd.notes()));
+        .newCommentFormatter()
+        .format(commentsUtil.publishedByChange(db.get(), cd.notes()));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
index 561a040..6a3e237 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
@@ -17,9 +17,9 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -34,17 +34,17 @@
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   @Inject
   ListChangeDrafts(Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
-      PatchLineCommentsUtil plcUtil) {
+      CommentsUtil commentsUtil) {
     this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
   @Override
@@ -54,11 +54,11 @@
       throw new AuthException("Authentication required");
     }
     ChangeData cd = changeDataFactory.create(db.get(), rsrc.getControl());
-    List<PatchLineComment> drafts = plcUtil.draftByChangeAuthor(
+    List<Comment> drafts = commentsUtil.draftByChangeAuthor(
         db.get(), cd.notes(), rsrc.getControl().getUser().getAccountId());
     return commentJson.get()
         .setFillAccounts(false)
         .setFillPatchSet(true)
-        .format(drafts);
+        .newCommentFormatter().format(drafts);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionComments.java
index 2392781..8524b8e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionComments.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -28,8 +28,8 @@
   @Inject
   ListRevisionComments(Provider<ReviewDb> db,
       Provider<CommentJson> commentJson,
-      PatchLineCommentsUtil plcUtil) {
-    super(db, commentJson, plcUtil);
+      CommentsUtil commentsUtil) {
+    super(db, commentJson, commentsUtil);
   }
 
   @Override
@@ -38,9 +38,10 @@
   }
 
   @Override
-  protected Iterable<PatchLineComment> listComments(RevisionResource rsrc)
+  protected Iterable<Comment> listComments(RevisionResource rsrc)
       throws OrmException {
     ChangeNotes notes = rsrc.getNotes();
-    return plcUtil.publishedByPatchSet(db.get(), notes, rsrc.getPatchSet().getId());
+    return commentsUtil.publishedByPatchSet(db.get(), notes,
+        rsrc.getPatchSet().getId());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java
index ef12b2a..21d427c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java
@@ -16,9 +16,9 @@
 
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -31,20 +31,20 @@
 public class ListRevisionDrafts implements RestReadView<RevisionResource> {
   protected final Provider<ReviewDb> db;
   protected final Provider<CommentJson> commentJson;
-  protected final PatchLineCommentsUtil plcUtil;
+  protected final CommentsUtil commentsUtil;
 
   @Inject
   ListRevisionDrafts(Provider<ReviewDb> db,
       Provider<CommentJson> commentJson,
-      PatchLineCommentsUtil plcUtil) {
+      CommentsUtil commentsUtil) {
     this.db = db;
     this.commentJson = commentJson;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
-  protected Iterable<PatchLineComment> listComments(RevisionResource rsrc)
+  protected Iterable<Comment> listComments(RevisionResource rsrc)
       throws OrmException {
-    return plcUtil.draftByPatchSetAuthor(db.get(), rsrc.getPatchSet().getId(),
+    return commentsUtil.draftByPatchSetAuthor(db.get(), rsrc.getPatchSet().getId(),
         rsrc.getAccountId(), rsrc.getNotes());
   }
 
@@ -57,13 +57,13 @@
       throws OrmException {
     return commentJson.get()
         .setFillAccounts(includeAuthorInfo())
-        .format(listComments(rsrc));
+        .newCommentFormatter().format(listComments(rsrc));
   }
 
   public List<CommentInfo> getComments(RevisionResource rsrc)
       throws OrmException {
     return commentJson.get()
         .setFillAccounts(includeAuthorInfo())
-        .formatAsList(listComments(rsrc));
+        .newCommentFormatter().formatAsList(listComments(rsrc));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java
new file mode 100644
index 0000000..01ad9ee
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class ListRobotComments implements RestReadView<RevisionResource> {
+  protected final Provider<ReviewDb> db;
+  protected final Provider<CommentJson> commentJson;
+  protected final CommentsUtil commentsUtil;
+
+  @Inject
+  ListRobotComments(Provider<ReviewDb> db,
+      Provider<CommentJson> commentJson,
+      CommentsUtil commentsUtil) {
+    this.db = db;
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public Map<String, List<RobotCommentInfo>> apply(RevisionResource rsrc)
+      throws OrmException {
+    return commentJson.get()
+        .setFillAccounts(true)
+        .newRobotCommentFormatter()
+        .format(listComments(rsrc));
+  }
+
+  public List<RobotCommentInfo> getComments(RevisionResource rsrc)
+      throws OrmException {
+    return commentJson.get()
+        .setFillAccounts(true)
+        .newRobotCommentFormatter()
+        .formatAsList(listComments(rsrc));
+  }
+
+  private Iterable<RobotComment> listComments(RevisionResource rsrc)
+      throws OrmException {
+    return commentsUtil.robotCommentsByPatchSet(
+        rsrc.getNotes(), rsrc.getPatchSet().getId());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index 62d75aa..a6e0935 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -64,12 +64,15 @@
 
   private static final String CACHE_NAME = "mergeability";
 
-  public static final BiMap<SubmitType, Character> SUBMIT_TYPES = ImmutableBiMap.of(
-        SubmitType.FAST_FORWARD_ONLY, 'F',
-        SubmitType.MERGE_IF_NECESSARY, 'M',
-        SubmitType.REBASE_IF_NECESSARY, 'R',
-        SubmitType.MERGE_ALWAYS, 'A',
-        SubmitType.CHERRY_PICK, 'C');
+  public static final BiMap<SubmitType, Character> SUBMIT_TYPES =
+      new ImmutableBiMap.Builder<SubmitType, Character>()
+          .put(SubmitType.FAST_FORWARD_ONLY, 'F')
+          .put(SubmitType.MERGE_IF_NECESSARY, 'M')
+          .put(SubmitType.REBASE_ALWAYS, 'P')
+          .put(SubmitType.REBASE_IF_NECESSARY, 'R')
+          .put(SubmitType.MERGE_ALWAYS, 'A')
+          .put(SubmitType.CHERRY_PICK, 'C')
+          .build();
 
   static {
     checkState(SUBMIT_TYPES.size() == SubmitType.values().length,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index 6de7deb..9ff9833 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.change.FileResource.FILE_KIND;
 import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
 import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
+import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
 import static com.google.gerrit.server.change.VoteResource.VOTE_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -37,11 +38,13 @@
     bind(Reviewers.class);
     bind(DraftComments.class);
     bind(Comments.class);
+    bind(RobotComments.class);
     bind(Files.class);
     bind(Votes.class);
 
     DynamicMap.mapOf(binder(), CHANGE_KIND);
     DynamicMap.mapOf(binder(), COMMENT_KIND);
+    DynamicMap.mapOf(binder(), ROBOT_COMMENT_KIND);
     DynamicMap.mapOf(binder(), DRAFT_COMMENT_KIND);
     DynamicMap.mapOf(binder(), FILE_KIND);
     DynamicMap.mapOf(binder(), REVIEWER_KIND);
@@ -50,9 +53,14 @@
     DynamicMap.mapOf(binder(), VOTE_KIND);
 
     get(CHANGE_KIND).to(GetChange.class);
+    post(CHANGE_KIND, "merge").to(CreateMergePatchSet.class);
     get(CHANGE_KIND, "detail").to(GetDetail.class);
     get(CHANGE_KIND, "topic").to(GetTopic.class);
     get(CHANGE_KIND, "in").to(IncludedIn.class);
+    get(CHANGE_KIND, "assignee").to(GetAssignee.class);
+    get(CHANGE_KIND, "past_assignees").to(GetPastAssignees.class);
+    put(CHANGE_KIND, "assignee").to(PutAssignee.class);
+    delete(CHANGE_KIND, "assignee").to(DeleteAssignee.class);
     get(CHANGE_KIND, "hashtags").to(GetHashtags.class);
     get(CHANGE_KIND, "comments").to(ListChangeComments.class);
     get(CHANGE_KIND, "drafts").to(ListChangeDrafts.class);
@@ -60,7 +68,7 @@
     post(CHANGE_KIND, "check").to(Check.class);
     put(CHANGE_KIND, "topic").to(PutTopic.class);
     delete(CHANGE_KIND, "topic").to(PutTopic.class);
-    delete(CHANGE_KIND).to(DeleteDraftChange.class);
+    delete(CHANGE_KIND).to(DeleteChange.class);
     post(CHANGE_KIND, "abandon").to(Abandon.class);
     post(CHANGE_KIND, "hashtags").to(PostHashtags.class);
     post(CHANGE_KIND, "publish").to(PublishDraftPatchSet.CurrentRevision.class);
@@ -78,6 +86,7 @@
     child(CHANGE_KIND, "reviewers").to(Reviewers.class);
     get(REVIEWER_KIND).to(GetReviewer.class);
     delete(REVIEWER_KIND).to(DeleteReviewer.class);
+    post(REVIEWER_KIND, "delete").to(DeleteReviewer.class);
     child(REVIEWER_KIND, "votes").to(Votes.class);
     delete(VOTE_KIND).to(DeleteVote.class);
     post(VOTE_KIND, "delete").to(DeleteVote.class);
@@ -92,6 +101,7 @@
     get(REVISION_KIND, "related").to(GetRelated.class);
     get(REVISION_KIND, "review").to(GetReview.class);
     post(REVISION_KIND, "review").to(PostReview.class);
+    get(REVISION_KIND, "preview_submit").to(PreviewSubmit.class);
     post(REVISION_KIND, "submit").to(Submit.class);
     post(REVISION_KIND, "rebase").to(Rebase.class);
     get(REVISION_KIND, "patch").to(GetPatch.class);
@@ -99,6 +109,7 @@
     post(REVISION_KIND, "test.submit_rule").to(TestSubmitRule.class);
     post(REVISION_KIND, "test.submit_type").to(TestSubmitType.class);
     get(REVISION_KIND, "archive").to(GetArchive.class);
+    get(REVISION_KIND, "mergelist").to(GetMergeList.class);
 
     child(REVISION_KIND, "drafts").to(DraftComments.class);
     put(REVISION_KIND, "drafts").to(CreateDraftComment.class);
@@ -109,6 +120,9 @@
     child(REVISION_KIND, "comments").to(Comments.class);
     get(COMMENT_KIND).to(GetComment.class);
 
+    child(REVISION_KIND, "robotcomments").to(RobotComments.class);
+    get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
+
     child(REVISION_KIND, "files").to(Files.class);
     put(FILE_KIND, "reviewed").to(PutReviewed.class);
     delete(FILE_KIND, "reviewed").to(DeleteReviewed.class);
@@ -136,6 +150,7 @@
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
+    factory(SetAssigneeOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
     factory(ChangeResource.Factory.class);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
index 2139ec4..4a3f45a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
@@ -33,8 +33,6 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -94,7 +92,7 @@
 
     try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
         req.getChange().getProject(), control.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getChange().getId(), new Op(control, input));
+      u.addOp(req.getChange().getId(), new Op(input));
       u.execute();
     }
 
@@ -103,14 +101,12 @@
 
   private class Op extends BatchUpdate.Op {
     private final MoveInput input;
-    private final IdentifiedUser caller;
 
     private Change change;
     private Branch.NameKey newDestKey;
 
-    Op(ChangeControl ctl, MoveInput input) {
+    Op(MoveInput input) {
       this.input = input;
-      this.caller = ctl.getUser().asIdentifiedUser();
     }
 
     @Override
@@ -179,11 +175,9 @@
         msgBuf.append("\n\n");
         msgBuf.append(input.message);
       }
-      ChangeMessage cmsg = new ChangeMessage(
-          new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          caller.getAccountId(), ctx.getWhen(), change.currentPatchSetId());
-      cmsg.setMessage(msgBuf.toString());
+      ChangeMessage cmsg =
+          ChangeMessagesUtil.newMessage(
+              ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE);
       cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
 
       return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
index 5bc3a36..2b31c71 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -30,30 +30,26 @@
 import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
-import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.ssh.NoSshInfo;
-import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
@@ -91,14 +87,13 @@
   private final ChangeControl origCtl;
 
   // Fields exposed as setters.
-  private SshInfo sshInfo;
   private String message;
   private CommitValidators.Policy validatePolicy =
       CommitValidators.Policy.GERRIT;
   private boolean draft;
   private List<String> groups = Collections.emptyList();
   private boolean fireRevisionCreated = true;
-  private boolean sendMail = true;
+  private NotifyHandling notify = NotifyHandling.ALL;
   private boolean allowClosed;
   private boolean copyApprovals = true;
 
@@ -144,11 +139,6 @@
     return this;
   }
 
-  public PatchSetInserter setSshInfo(SshInfo sshInfo) {
-    this.sshInfo = sshInfo;
-    return this;
-  }
-
   public PatchSetInserter setValidatePolicy(CommitValidators.Policy validate) {
     this.validatePolicy = checkNotNull(validate);
     return this;
@@ -170,8 +160,8 @@
     return this;
   }
 
-  public PatchSetInserter setSendMail(boolean sendMail) {
-    this.sendMail = sendMail;
+  public PatchSetInserter setNotify(NotifyHandling notify) {
+    this.notify = notify;
     return this;
   }
 
@@ -198,7 +188,6 @@
   @Override
   public void updateRepo(RepoContext ctx)
       throws AuthException, ResourceConflictException, IOException, OrmException {
-    init();
     validate(ctx);
     ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(),
         commit, getPatchSetId().toRefName(), ReceiveCommand.Type.CREATE));
@@ -230,14 +219,14 @@
     patchSet = psUtil.insert(db, ctx.getRevWalk(), ctx.getUpdate(psId),
         psId, commit, draft, newGroups, null);
 
-    if (sendMail) {
+    if (notify != NotifyHandling.NONE) {
       oldReviewers = approvalsUtil.getReviewers(db, ctl.getNotes());
     }
 
     if (message != null) {
-      changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(ctl.getId(), ChangeUtil.messageUUID(db)),
-          ctx.getAccountId(), ctx.getWhen(), patchSet.getId());
+      changeMessage = ChangeMessagesUtil.newMessage(
+          db, patchSet.getId(), ctx.getUser(), ctx.getWhen(), message,
+          ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
       changeMessage.setMessage(message);
     }
 
@@ -257,7 +246,7 @@
 
   @Override
   public void postUpdate(Context ctx) throws OrmException {
-    if (sendMail) {
+    if (notify != NotifyHandling.NONE) {
       try {
         ReplacePatchSetSender cm = replacePatchSetFactory.create(
             ctx.getProject(), change.getId());
@@ -266,6 +255,7 @@
         cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
         cm.addReviewers(oldReviewers.byState(REVIEWER));
         cm.addExtraCC(oldReviewers.byState(CC));
+        cm.setNotify(notify);
         cm.send();
       } catch (Exception err) {
         log.error("Cannot send email for new patch set on change "
@@ -273,30 +263,21 @@
       }
     }
 
-    NotifyHandling notify = sendMail
-        ? NotifyHandling.ALL
-        : NotifyHandling.NONE;
     if (fireRevisionCreated) {
       revisionCreated.fire(change, patchSet, ctx.getAccount(),
           ctx.getWhen(), notify);
     }
   }
 
-  private void init() {
-    if (sshInfo == null) {
-      sshInfo = new NoSshInfo();
-    }
-  }
-
   private void validate(RepoContext ctx)
       throws AuthException, ResourceConflictException, IOException,
       OrmException {
-    CommitValidators cv = commitValidatorsFactory.create(
-        origCtl.getRefControl(), sshInfo, ctx.getRepository());
-
     if (!origCtl.canAddPatchSet(ctx.getDb())) {
       throw new AuthException("cannot add patch set");
     }
+    if (validatePolicy == CommitValidators.Policy.NONE) {
+      return;
+    }
 
     String refName = getPatchSetId().toRefName();
     CommitReceivedEvent event = new CommitReceivedEvent(
@@ -309,18 +290,11 @@
         commit, ctx.getIdentifiedUser());
 
     try {
-      switch (validatePolicy) {
-      case RECEIVE_COMMITS:
-        NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(
-            ctx.getRepository(), ctx.getRevWalk());
-        cv.validateForReceiveCommits(event, rejectCommits);
-        break;
-      case GERRIT:
-        cv.validateForGerritCommits(event);
-        break;
-      case NONE:
-        break;
-      }
+      commitValidatorsFactory
+          .create(
+              validatePolicy, origCtl.getRefControl(), new NoSshInfo(),
+              ctx.getRepository())
+          .validate(event);
     } catch (CommitValidationException e) {
       throw new ResourceConflictException(e.getFullMessage());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index aa35da8..c930c82 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
-import static com.google.gerrit.server.change.PutDraftComment.side;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toSet;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.auto.value.AutoValue;
@@ -44,29 +45,36 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.CommentRange;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.git.BatchUpdate;
@@ -75,6 +83,7 @@
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -93,7 +102,6 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -109,13 +117,14 @@
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final AccountsCollection accounts;
   private final EmailReviewComments.Factory email;
   private final CommentAdded commentAdded;
   private final PostReviewers postReviewers;
+  private final NotesMigration migration;
 
   @Inject
   PostReview(Provider<ReviewDb> db,
@@ -124,18 +133,19 @@
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       AccountsCollection accounts,
       EmailReviewComments.Factory email,
       CommentAdded commentAdded,
-      PostReviewers postReviewers) {
+      PostReviewers postReviewers,
+      NotesMigration migration) {
     this.db = db;
     this.batchUpdateFactory = batchUpdateFactory;
     this.changes = changes;
     this.changeDataFactory = changeDataFactory;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
@@ -144,6 +154,7 @@
     this.email = email;
     this.commentAdded = commentAdded;
     this.postReviewers = postReviewers;
+    this.migration = migration;
   }
 
   @Override
@@ -162,6 +173,8 @@
     }
     if (input.onBehalfOf != null) {
       revision = onBehalfOf(revision, input);
+    } else if (input.drafts == null) {
+      input.drafts = DraftHandling.DELETE;
     }
     if (input.labels != null) {
       checkLabels(revision, input.strictLabels, input.labels);
@@ -169,6 +182,12 @@
     if (input.comments != null) {
       checkComments(revision, input.comments);
     }
+    if (input.robotComments != null) {
+      if (!migration.readChanges()) {
+        throw new MethodNotAllowedException("robot comments not supported");
+      }
+      checkRobotComments(revision, input.robotComments);
+    }
     if (input.notify == null) {
       log.warn("notify = null; assuming notify = NONE");
       input.notify = NotifyHandling.NONE;
@@ -181,6 +200,9 @@
     if (input.reviewers != null) {
       reviewerJsonResults = Maps.newHashMap();
       for (AddReviewerInput reviewerInput : input.reviewers) {
+        // Prevent notifications because setting reviewers is batched.
+        reviewerInput.notify = NotifyHandling.NONE;
+
         PostReviewers.Addition result = postReviewers.prepareApplication(
             revision.getChangeResource(), reviewerInput);
         reviewerJsonResults.put(reviewerInput.reviewer, result.result);
@@ -212,17 +234,33 @@
       }
       bu.addOp(
           revision.getChange().getId(),
-          new Op(revision.getPatchSet().getId(), input));
+          new Op(revision.getPatchSet().getId(), input, reviewerResults));
       bu.execute();
 
       for (PostReviewers.Addition reviewerResult : reviewerResults) {
         reviewerResult.gatherResults();
       }
+
+      emailReviewers(revision.getChange(), reviewerResults, input.notify);
     }
 
     return Response.ok(output);
   }
 
+  private void emailReviewers(Change change,
+      List<PostReviewers.Addition> reviewerAdditions, NotifyHandling notify) {
+    List<Account.Id> to = new ArrayList<>();
+    List<Account.Id> cc = new ArrayList<>();
+    for (PostReviewers.Addition addition : reviewerAdditions) {
+      if (addition.op.state == ReviewerState.REVIEWER) {
+        to.addAll(addition.op.reviewers.keySet());
+      } else if (addition.op.state == ReviewerState.CC) {
+        cc.addAll(addition.op.reviewers.keySet());
+      }
+    }
+    postReviewers.emailReviewers(change, to, cc, notify);
+  }
+
   private RevisionResource onBehalfOf(RevisionResource rev, ReviewInput in)
       throws BadRequestException, AuthException, UnprocessableEntityException,
       OrmException {
@@ -231,6 +269,13 @@
           "label required to post review on behalf of \"%s\"",
           in.onBehalfOf));
     }
+    if (in.drafts == null) {
+      in.drafts = DraftHandling.KEEP;
+    }
+    if (in.drafts != DraftHandling.KEEP) {
+      throw new AuthException("not allowed to modify other user's drafts");
+    }
+
 
     ChangeControl caller = rev.getControl();
     Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
@@ -245,6 +290,10 @@
         continue;
       }
 
+      if (caller.getUser().isInternalUser()) {
+        continue;
+      }
+
       PermissionRange r = caller.getRange(Permission.forLabelAs(type.getName()));
       if (r == null || r.isEmpty() || !r.contains(ent.getValue())) {
         throw new AuthException(String.format(
@@ -258,7 +307,13 @@
           in.onBehalfOf));
     }
 
-    ChangeControl target = caller.forUser(accounts.parse(in.onBehalfOf));
+    ChangeControl target = caller.forUser(
+        accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
+    if (!target.getRefControl().isVisible()) {
+      throw new UnprocessableEntityException(String.format(
+          "on_behalf_of account %s cannot see destination ref",
+          target.getUser().getAccountId()));
+    }
     return new RevisionResource(changes.parse(target), rev.getPatchSet());
   }
 
@@ -312,32 +367,40 @@
     }
   }
 
-  private void checkComments(RevisionResource revision, Map<String, List<CommentInput>> in)
-      throws BadRequestException, OrmException {
-    Iterator<Map.Entry<String, List<CommentInput>>> mapItr =
-        in.entrySet().iterator();
+  private <T extends CommentInput> void checkComments(RevisionResource revision,
+      Map<String, List<T>> in) throws BadRequestException, OrmException {
+    Iterator<? extends Map.Entry<String, List<T>>> mapItr =
+            in.entrySet().iterator();
     Set<String> filePaths =
         Sets.newHashSet(changeDataFactory.create(
             db.get(), revision.getControl()).filePaths(
                 revision.getPatchSet()));
     while (mapItr.hasNext()) {
-      Map.Entry<String, List<CommentInput>> ent = mapItr.next();
+      Map.Entry<String, List<T>> ent = mapItr.next();
       String path = ent.getKey();
-      if (!filePaths.contains(path) && !Patch.COMMIT_MSG.equals(path)) {
+      if (!filePaths.contains(path) && !Patch.isMagic(path)) {
         throw new BadRequestException(String.format(
             "file %s not found in revision %s",
             path, revision.getChange().currentPatchSetId()));
       }
 
-      List<CommentInput> list = ent.getValue();
+      List<T> list = ent.getValue();
       if (list == null) {
         mapItr.remove();
         continue;
       }
+      if (Patch.isMagic(path)) {
+        for (T comment : list) {
+          if (comment.side == Side.PARENT && comment.parent == null) {
+            throw new BadRequestException(
+                String.format("cannot comment on %s on auto-merge", path));
+          }
+        }
+      }
 
-      Iterator<CommentInput> listItr = list.iterator();
+      Iterator<T> listItr = list.iterator();
       while (listItr.hasNext()) {
-        CommentInput c = listItr.next();
+        T c = listItr.next();
         if (c == null) {
           listItr.remove();
           continue;
@@ -358,48 +421,72 @@
     }
   }
 
+  private void checkRobotComments(RevisionResource revision,
+      Map<String, List<RobotCommentInput>> in)
+          throws BadRequestException, OrmException {
+    for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
+      String path = e.getKey();
+      for (RobotCommentInput c : e.getValue()) {
+        if (c.robotId == null) {
+          throw new BadRequestException(String
+              .format("robotId is missing for robot comment on %s", path));
+        }
+        if (c.robotRunId == null) {
+          throw new BadRequestException(String
+              .format("robotRunId is missing for robot comment on %s", path));
+        }
+      }
+    }
+    checkComments(revision, in);
+  }
+
   /**
-   * Used to compare PatchLineComments with CommentInput comments.
+   * Used to compare Comments with CommentInput comments.
    */
   @AutoValue
   abstract static class CommentSetEntry {
-    private static CommentSetEntry create(Patch.Key key,
-        Integer line, Side side, HashCode message, CommentRange range) {
-      return new AutoValue_PostReview_CommentSetEntry(key, line, side, message,
-          range);
+    private static CommentSetEntry create(String filename, int patchSetId,
+        Integer line, Side side, HashCode message, Comment.Range range) {
+      return new AutoValue_PostReview_CommentSetEntry(filename, patchSetId,
+          line, side, message, range);
     }
 
-    public static CommentSetEntry create(PatchLineComment comment) {
-      return create(comment.getKey().getParentKey(),
-          comment.getLine(),
-          Side.fromShort(comment.getSide()),
-          Hashing.sha1().hashString(comment.getMessage(), UTF_8),
-          comment.getRange());
+    public static CommentSetEntry create(Comment comment) {
+      return create(comment.key.filename,
+          comment.key.patchSetId,
+          comment.lineNbr,
+          Side.fromShort(comment.side),
+          Hashing.sha1().hashString(comment.message, UTF_8),
+          comment.range);
     }
 
-    abstract Patch.Key key();
+    abstract String filename();
+    abstract int patchSetId();
     @Nullable abstract Integer line();
     abstract Side side();
     abstract HashCode message();
-    @Nullable abstract CommentRange range();
+    @Nullable abstract Comment.Range range();
   }
 
   private class Op extends BatchUpdate.Op {
     private final PatchSet.Id psId;
     private final ReviewInput in;
+    private final List<PostReviewers.Addition> reviewerResults;
 
     private IdentifiedUser user;
     private ChangeNotes notes;
     private PatchSet ps;
     private ChangeMessage message;
-    private List<PatchLineComment> comments = new ArrayList<>();
+    private List<Comment> comments = new ArrayList<>();
     private List<String> labelDelta = new ArrayList<>();
     private Map<String, Short> approvals = new HashMap<>();
     private Map<String, Short> oldApprovals = new HashMap<>();
 
-    private Op(PatchSet.Id psId, ReviewInput in) {
+    private Op(PatchSet.Id psId, ReviewInput in,
+        List<PostReviewers.Addition> reviewerResults) {
       this.psId = psId;
       this.in = in;
+      this.reviewerResults = reviewerResults;
     }
 
     @Override
@@ -410,6 +497,7 @@
       ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       boolean dirty = false;
       dirty |= insertComments(ctx);
+      dirty |= insertRobotComments(ctx);
       dirty |= updateLabels(ctx);
       dirty |= insertMessage(ctx);
       return dirty;
@@ -440,7 +528,7 @@
         map = Collections.emptyMap();
       }
 
-      Map<String, PatchLineComment> drafts = Collections.emptyMap();
+      Map<String, Comment> drafts = Collections.emptyMap();
       if (!map.isEmpty() || in.drafts != DraftHandling.KEEP) {
         if (in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS) {
           drafts = changeDrafts(ctx);
@@ -449,101 +537,127 @@
         }
       }
 
-      List<PatchLineComment> del = new ArrayList<>();
-      List<PatchLineComment> ups = new ArrayList<>();
+      List<Comment> toDel = new ArrayList<>();
+      List<Comment> toPublish = new ArrayList<>();
 
       Set<CommentSetEntry> existingIds = in.omitDuplicateComments
           ? readExistingComments(ctx)
-          : Collections.<CommentSetEntry>emptySet();
+          : Collections.emptySet();
 
       for (Map.Entry<String, List<CommentInput>> ent : map.entrySet()) {
         String path = ent.getKey();
         for (CommentInput c : ent.getValue()) {
           String parent = Url.decode(c.inReplyTo);
-          PatchLineComment e = drafts.remove(Url.decode(c.id));
+          Comment e = drafts.remove(Url.decode(c.id));
           if (e == null) {
-            e = new PatchLineComment(
-                new PatchLineComment.Key(new Patch.Key(psId, path), null),
-                c.line != null ? c.line : 0,
-                user.getAccountId(),
-                parent, ctx.getWhen());
-          } else if (parent != null) {
-            e.setParentUuid(parent);
+            e = commentsUtil.newComment(ctx, path, psId, c.side(), c.message);
+          } else {
+            e.writtenOn = ctx.getWhen();
+            e.side = c.side();
+            e.message = c.message;
           }
-          e.setStatus(PatchLineComment.Status.PUBLISHED);
-          e.setWrittenOn(ctx.getWhen());
-          e.setSide(side(c));
+
+          if (parent != null) {
+            e.parentUuid = parent;
+          }
           setCommentRevId(e, patchListCache, ctx.getChange(), ps);
-          e.setMessage(c.message);
-          e.setTag(in.tag);
-          if (c.range != null) {
-            e.setRange(new CommentRange(
-                c.range.startLine,
-                c.range.startCharacter,
-                c.range.endLine,
-                c.range.endCharacter));
-            e.setLine(c.range.endLine);
-          }
+          e.setLineNbrAndRange(c.line, c.range);
+          e.tag = in.tag;
+
           if (existingIds.contains(CommentSetEntry.create(e))) {
             continue;
           }
-          if (e.getKey().get() == null) {
-            e.getKey().set(ChangeUtil.messageUUID(ctx.getDb()));
-          }
-          ups.add(e);
+          toPublish.add(e);
         }
       }
 
-      switch (firstNonNull(in.drafts, DraftHandling.DELETE)) {
+      switch (in.drafts) {
         case KEEP:
         default:
           break;
         case DELETE:
-          del.addAll(drafts.values());
+          toDel.addAll(drafts.values());
           break;
         case PUBLISH:
-          for (PatchLineComment e : drafts.values()) {
-            ups.add(publishComment(ctx, e, ps));
+          for (Comment e : drafts.values()) {
+            toPublish.add(publishComment(ctx, e, ps));
           }
           break;
         case PUBLISH_ALL_REVISIONS:
-          publishAllRevisions(ctx, drafts, ups);
+          publishAllRevisions(ctx, drafts, toPublish);
           break;
       }
       ChangeUpdate u = ctx.getUpdate(psId);
-      plcUtil.deleteComments(ctx.getDb(), u, del);
-      plcUtil.putComments(ctx.getDb(), u, ups);
-      comments.addAll(ups);
-      return !del.isEmpty() || !ups.isEmpty();
+      commentsUtil.deleteComments(ctx.getDb(), u, toDel);
+      commentsUtil.putComments(ctx.getDb(), u, Status.PUBLISHED, toPublish);
+      comments.addAll(toPublish);
+      return !toDel.isEmpty() || !toPublish.isEmpty();
+    }
+
+    private boolean insertRobotComments(ChangeContext ctx) throws OrmException {
+      if (in.robotComments == null) {
+        return false;
+      }
+
+      List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
+
+      Set<CommentSetEntry> existingIds = in.omitDuplicateComments
+          ? readExistingRobotComments(ctx)
+          : Collections.emptySet();
+
+      for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
+        String path = ent.getKey();
+        for (RobotCommentInput c : ent.getValue()) {
+          RobotComment e = commentsUtil.newRobotComment(
+              ctx, path, psId, c.side(), c.message, c.robotId, c.robotRunId);
+          e.parentUuid = Url.decode(c.inReplyTo);
+          e.url = c.url;
+          e.properties = c.properties;
+          e.setLineNbrAndRange(c.line, c.range);
+          e.tag = in.tag;
+          setCommentRevId(e, patchListCache, ctx.getChange(), ps);
+
+          if (existingIds.contains(CommentSetEntry.create(e))) {
+            continue;
+          }
+          toAdd.add(e);
+        }
+      }
+
+      commentsUtil.putRobotComments(ctx.getUpdate(psId), toAdd);
+      comments.addAll(toAdd);
+      return !toAdd.isEmpty();
     }
 
     private Set<CommentSetEntry> readExistingComments(ChangeContext ctx)
         throws OrmException {
-      Set<CommentSetEntry> r = new HashSet<>();
-      for (PatchLineComment c : plcUtil.publishedByChange(ctx.getDb(),
-            ctx.getNotes())) {
-        r.add(CommentSetEntry.create(c));
-      }
-      return r;
+      return commentsUtil.publishedByChange(ctx.getDb(), ctx.getNotes())
+          .stream().map(CommentSetEntry::create).collect(toSet());
     }
 
-    private Map<String, PatchLineComment> changeDrafts(ChangeContext ctx)
+    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx)
         throws OrmException {
-      Map<String, PatchLineComment> drafts = new HashMap<>();
-      for (PatchLineComment c : plcUtil.draftByChangeAuthor(
+      return commentsUtil.robotCommentsByChange(ctx.getNotes())
+          .stream().map(CommentSetEntry::create).collect(toSet());
+    }
+
+    private Map<String, Comment> changeDrafts(ChangeContext ctx)
+        throws OrmException {
+      Map<String, Comment> drafts = new HashMap<>();
+      for (Comment c : commentsUtil.draftByChangeAuthor(
           ctx.getDb(), ctx.getNotes(), user.getAccountId())) {
-        c.setTag(in.tag);
-        drafts.put(c.getKey().get(), c);
+        c.tag = in.tag;
+        drafts.put(c.key.uuid, c);
       }
       return drafts;
     }
 
-    private Map<String, PatchLineComment> patchSetDrafts(ChangeContext ctx)
+    private Map<String, Comment> patchSetDrafts(ChangeContext ctx)
         throws OrmException {
-      Map<String, PatchLineComment> drafts = new HashMap<>();
-      for (PatchLineComment c : plcUtil.draftByPatchSetAuthor(ctx.getDb(),
+      Map<String, Comment> drafts = new HashMap<>();
+      for (Comment c : commentsUtil.draftByPatchSetAuthor(ctx.getDb(),
           psId, user.getAccountId(), ctx.getNotes())) {
-        drafts.put(c.getKey().get(), c);
+        drafts.put(c.key.uuid, c);
       }
       return drafts;
     }
@@ -557,21 +671,24 @@
       return labels;
     }
 
-    private PatchLineComment publishComment(ChangeContext ctx,
-        PatchLineComment c, PatchSet ps) throws OrmException {
-      c.setStatus(PatchLineComment.Status.PUBLISHED);
-      c.setWrittenOn(ctx.getWhen());
-      c.setTag(in.tag);
+    private Comment publishComment(ChangeContext ctx,
+        Comment c, PatchSet ps) throws OrmException {
+      c.writtenOn = ctx.getWhen();
+      c.tag = in.tag;
+      // Draft may have been created by a different real user; copy the current
+      // real user. (Only applies to X-Gerrit-RunAs, since modifying drafts via
+      // on_behalf_of is not allowed.)
+      ctx.getUser().updateRealAccountId(c::setRealAuthor);
       setCommentRevId(c, patchListCache, ctx.getChange(), checkNotNull(ps));
       return c;
     }
 
     private void publishAllRevisions(ChangeContext ctx,
-        Map<String, PatchLineComment> drafts, List<PatchLineComment> ups)
+        Map<String, Comment> drafts, List<Comment> ups)
         throws OrmException {
       boolean needOtherPatchSets = false;
-      for (PatchLineComment c : drafts.values()) {
-        if (!c.getPatchSetId().equals(psId)) {
+      for (Comment c : drafts.values()) {
+        if (c.key.patchSetId != psId.get()) {
           needOtherPatchSets = true;
           break;
         }
@@ -579,8 +696,9 @@
       Map<PatchSet.Id, PatchSet> patchSets = needOtherPatchSets
           ? psUtil.byChangeAsMap(ctx.getDb(), ctx.getNotes())
           : ImmutableMap.of(psId, ps);
-      for (PatchLineComment e : drafts.values()) {
-        ups.add(publishComment(ctx, e, patchSets.get(e.getPatchSetId())));
+      for (Comment e : drafts.values()) {
+        ups.add(publishComment(ctx, e, patchSets
+            .get(new PatchSet.Id(ctx.getChange().getId(), e.key.patchSetId))));
       }
     }
 
@@ -615,6 +733,28 @@
       return previous;
     }
 
+    private boolean isReviewer(ChangeContext ctx) throws OrmException {
+      if (ctx.getAccountId().equals(ctx.getChange().getOwner())) {
+        return true;
+      }
+      for (PostReviewers.Addition addition : reviewerResults) {
+        if (addition.op.addedReviewers == null) {
+          continue;
+        }
+        for (PatchSetApproval psa : addition.op.addedReviewers) {
+          if (psa.getAccountId().equals(ctx.getAccountId())) {
+            return true;
+          }
+        }
+      }
+      ChangeData cd = changeDataFactory.create(db.get(), ctx.getControl());
+      ReviewerSet reviewers = cd.reviewers();
+      if (reviewers.byState(REVIEWER).contains(ctx.getAccountId())) {
+        return true;
+      }
+      return false;
+    }
+
     private boolean updateLabels(ChangeContext ctx)
         throws OrmException, ResourceConflictException {
       Map<String, Short> inLabels = MoreObjects.firstNonNull(in.labels,
@@ -659,6 +799,7 @@
           c.setValue(ent.getValue());
           c.setGranted(ctx.getWhen());
           c.setTag(in.tag);
+          ctx.getUser().updateRealAccountId(c::setRealAccountId);
           ups.add(c);
           addLabelDelta(normName, c.getValue());
           oldApprovals.put(normName, previous.get(normName));
@@ -669,11 +810,8 @@
           oldApprovals.put(normName, null);
           approvals.put(normName, c.getValue());
         } else if (c == null) {
-          c = new PatchSetApproval(new PatchSetApproval.Key(
-                  psId,
-                  user.getAccountId(),
-                  lt.getLabelId()),
-              ent.getValue(), ctx.getWhen());
+          c = ApprovalsUtil.newApproval(
+              psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen());
           c.setTag(in.tag);
           c.setGranted(ctx.getWhen());
           ups.add(c);
@@ -685,16 +823,64 @@
         }
       }
 
-      if ((!del.isEmpty() || !ups.isEmpty())
-          && ctx.getChange().getStatus().isClosed()) {
-        throw new ResourceConflictException("change is closed");
+      validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
+
+      // Return early if user is not a reviewer and not posting any labels.
+      // This allows us to preserve their CC status.
+      if (current.isEmpty() && del.isEmpty() && ups.isEmpty() &&
+          !isReviewer(ctx)) {
+        return false;
       }
+
       forceCallerAsReviewer(ctx, current, ups, del);
       ctx.getDb().patchSetApprovals().delete(del);
       ctx.getDb().patchSetApprovals().upsert(ups);
       return !del.isEmpty() || !ups.isEmpty();
     }
 
+    private void validatePostSubmitLabels(ChangeContext ctx,
+        LabelTypes labelTypes, Map<String, Short> previous,
+        List<PatchSetApproval> ups, List<PatchSetApproval> del)
+        throws ResourceConflictException {
+      if (ctx.getChange().getStatus().isOpen()) {
+        return; // Not closed, nothing to validate.
+      } else if (del.isEmpty() && ups.isEmpty()) {
+        return; // No new votes.
+      } else if (ctx.getChange().getStatus() != Change.Status.MERGED) {
+        throw new ResourceConflictException("change is closed");
+      }
+
+      // Disallow reducing votes on any labels post-submit. This assumes the
+      // high values were broadly necessary to submit, so reducing them would
+      // make it possible to take a merged change and make it no longer
+      // submittable.
+      List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
+      reduced.addAll(del);
+      for (PatchSetApproval psa : ups) {
+        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
+        String normName = lt.getName();
+        Short prev = previous.get(normName);
+        if (prev == null) {
+          continue;
+        }
+        checkState(prev != psa.getValue()); // Should be filtered out above.
+        if (prev > psa.getValue()) {
+          reduced.add(psa);
+        } else {
+          // Set postSubmit bit in ReviewDb; not required for NoteDb, which sets
+          // it automatically.
+          psa.setPostSubmit(true);
+        }
+      }
+
+      if (!reduced.isEmpty()) {
+        throw new ResourceConflictException(
+            "Cannot reduce vote on labels for closed change: "
+                + reduced.stream().map(p -> p.getLabel()).distinct().sorted()
+                    .collect(joining(", ")));
+      }
+    }
+
     private void forceCallerAsReviewer(ChangeContext ctx,
         Map<String, PatchSetApproval> current, List<PatchSetApproval> ups,
         List<PatchSetApproval> del) {
@@ -703,12 +889,10 @@
         if (del.isEmpty()) {
           // If no existing label is being set to 0, hack in the caller
           // as a reviewer by picking the first server-wide LabelType.
-          PatchSetApproval c = new PatchSetApproval(new PatchSetApproval.Key(
-              psId,
-              user.getAccountId(),
-              ctx.getControl().getLabelTypes().getLabelTypes().get(0)
-                  .getLabelId()),
-              (short) 0, ctx.getWhen());
+          LabelId labelId = ctx.getControl().getLabelTypes().getLabelTypes()
+              .get(0).getLabelId();
+          PatchSetApproval c = ApprovalsUtil.newApproval(
+              psId, user, labelId, 0, ctx.getWhen());
           c.setTag(in.tag);
           c.setGranted(ctx.getWhen());
           ups.add(c);
@@ -767,17 +951,9 @@
         return false;
       }
 
-      message = new ChangeMessage(
-          new ChangeMessage.Key(
-            psId.getParentKey(), ChangeUtil.messageUUID(ctx.getDb())),
-          user.getAccountId(),
-          ctx.getWhen(),
-          psId);
-      message.setTag(in.tag);
-      message.setMessage(String.format(
-          "Patch Set %d:%s",
-          psId.get(),
-          buf.toString()));
+      message = ChangeMessagesUtil.newMessage(
+          ctx.getDb(), psId, user, ctx.getWhen(),
+          "Patch Set " + psId.get() + ":" + buf, in.tag);
       cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), message);
       return true;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
index fb37d9d..420c05c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -52,7 +53,7 @@
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.AddReviewerSender;
+import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -96,10 +97,10 @@
   private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final Config cfg;
-  private final AccountCache accountCache;
   private final ReviewerJson json;
   private final ReviewerAdded reviewerAdded;
   private final NotesMigration migration;
+  private final AccountCache accountCache;
 
   @Inject
   PostReviewers(AccountsCollection accounts,
@@ -115,10 +116,10 @@
       Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory identifiedUserFactory,
       @GerritServerConfig Config cfg,
-      AccountCache accountCache,
       ReviewerJson json,
       ReviewerAdded reviewerAdded,
-      NotesMigration migration) {
+      NotesMigration migration,
+      AccountCache accountCache) {
     this.accounts = accounts;
     this.reviewerFactory = reviewerFactory;
     this.approvalsUtil = approvalsUtil;
@@ -132,10 +133,10 @@
     this.user = user;
     this.identifiedUserFactory = identifiedUserFactory;
     this.cfg = cfg;
-    this.accountCache = accountCache;
     this.json = json;
     this.reviewerAdded = reviewerAdded;
     this.migration = migration;
+    this.accountCache = accountCache;
   }
 
   @Override
@@ -173,18 +174,24 @@
       }
     }
     return putAccount(input.reviewer, reviewerFactory.create(rsrc, accountId),
-        input.state());
+        input.state(), input.notify);
   }
 
   private Addition putAccount(String reviewer, ReviewerResource rsrc,
-      ReviewerState state) throws UnprocessableEntityException {
+      ReviewerState state, NotifyHandling notify)
+      throws UnprocessableEntityException {
     Account member = rsrc.getReviewerUser().getAccount();
     ChangeControl control = rsrc.getReviewerControl();
     if (isValidReviewer(member, control)) {
       return new Addition(reviewer, rsrc.getChangeResource(),
-          ImmutableMap.of(member.getId(), control), state);
+          ImmutableMap.of(member.getId(), control), state, notify);
     }
-    throw new UnprocessableEntityException("Change not visible to " + reviewer);
+    if (member.isActive()) {
+      throw new UnprocessableEntityException(
+          String.format("Change not visible to %s", reviewer));
+    }
+    throw new UnprocessableEntityException(
+        String.format("Account of %s is inactive.", reviewer));
   }
 
   private Addition putGroup(ChangeResource rsrc, AddReviewerInput input)
@@ -234,7 +241,8 @@
       }
     }
 
-    return new Addition(input.reviewer, rsrc, reviewers, input.state());
+    return new Addition(input.reviewer, rsrc, reviewers, input.state(),
+        input.notify);
   }
 
   private boolean isValidReviewer(Account member, ChangeControl control) {
@@ -258,18 +266,19 @@
     return addition;
   }
 
-  class Addition {
+  public class Addition {
     final AddReviewerResult result;
     final Op op;
 
     private final Map<Account.Id, ChangeControl> reviewers;
 
     protected Addition(String reviewer) {
-      this(reviewer, null, null, REVIEWER);
+      this(reviewer, null, null, REVIEWER, null);
     }
 
     protected Addition(String reviewer, ChangeResource rsrc,
-        Map<Account.Id, ChangeControl> reviewers, ReviewerState state) {
+        Map<Account.Id, ChangeControl> reviewers, ReviewerState state,
+        NotifyHandling notify) {
       result = new AddReviewerResult(reviewer);
       if (reviewers == null) {
         this.reviewers = ImmutableMap.of();
@@ -277,7 +286,7 @@
         return;
       }
       this.reviewers = reviewers;
-      op = new Op(rsrc, reviewers, state);
+      op = new Op(rsrc, reviewers, state, notify);
     }
 
     void gatherResults() throws OrmException {
@@ -304,9 +313,10 @@
     }
   }
 
-  class Op extends BatchUpdate.Op {
+  public class Op extends BatchUpdate.Op {
     final Map<Account.Id, ChangeControl> reviewers;
     final ReviewerState state;
+    final NotifyHandling notify;
     List<PatchSetApproval> addedReviewers;
     Collection<Account.Id> addedCCs;
 
@@ -314,10 +324,11 @@
     private PatchSet patchSet;
 
     Op(ChangeResource rsrc, Map<Account.Id, ChangeControl> reviewers,
-        ReviewerState state) {
+        ReviewerState state, NotifyHandling notify) {
       this.rsrc = rsrc;
       this.reviewers = reviewers;
       this.state = state;
+      this.notify = notify;
     }
 
     @Override
@@ -353,20 +364,21 @@
         if (addedCCs == null) {
           addedCCs = new ArrayList<>();
         }
-        emailReviewers(rsrc.getChange(), addedReviewers, addedCCs);
+        emailReviewers(rsrc.getChange(),
+            Lists.transform(addedReviewers, r -> r.getAccountId()), addedCCs,
+            notify);
         if (!addedReviewers.isEmpty()) {
-          for (PatchSetApproval psa : addedReviewers) {
-            Account account = accountCache.get(psa.getAccountId()).getAccount();
-            reviewerAdded.fire(rsrc.getChange(), patchSet, account,
+          List<Account> reviewers = Lists.transform(addedReviewers,
+              psa -> accountCache.get(psa.getAccountId()).getAccount());
+          reviewerAdded.fire(rsrc.getChange(), patchSet, reviewers,
               ctx.getAccount(), ctx.getWhen());
-          }
         }
       }
     }
   }
 
-  private void emailReviewers(Change change, List<PatchSetApproval> added,
-      Collection<Account.Id> copied) {
+  public void emailReviewers(Change change, Collection<Account.Id> added,
+      Collection<Account.Id> copied, NotifyHandling notify) {
     if (added.isEmpty() && copied.isEmpty()) {
       return;
     }
@@ -376,9 +388,9 @@
     // The user knows they added themselves, don't bother emailing them.
     List<Account.Id> toMail = Lists.newArrayListWithCapacity(added.size());
     Account.Id userId = user.get().getAccountId();
-    for (PatchSetApproval psa : added) {
-      if (!psa.getAccountId().equals(userId)) {
-        toMail.add(psa.getAccountId());
+    for (Account.Id id : added) {
+      if (!id.equals(userId)) {
+        toMail.add(id);
       }
     }
     List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
@@ -393,7 +405,7 @@
 
     try {
       AddReviewerSender cm = addReviewerSenderFactory
-          .create(change.getProject(), change.getId());
+          .create(change.getProject(), change.getId(), notify);
       cm.setFrom(userId);
       cm.addReviewers(toMail);
       cm.addExtraCC(toCopy);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
new file mode 100644
index 0000000..435832a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
@@ -0,0 +1,149 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.git.MergeOp;
+import com.google.gerrit.server.git.MergeOpRepoManager;
+import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.apache.commons.compress.archivers.ArchiveOutputStream;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.transport.BundleWriter;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.kohsuke.args4j.Option;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Collection;
+import java.util.Set;
+
+@Singleton
+public class PreviewSubmit implements RestReadView<RevisionResource> {
+  private final Provider<ReviewDb> dbProvider;
+  private final Provider<MergeOp> mergeOpProvider;
+  private final AllowedFormats allowedFormats;
+
+  private String format;
+
+  @Option(name = "--format")
+  public void setFormat(String f) {
+    this.format = f;
+  }
+
+  @Inject
+  PreviewSubmit(Provider<ReviewDb> dbProvider,
+      Provider<MergeOp> mergeOpProvider,
+      AllowedFormats allowedFormats) {
+    this.dbProvider = dbProvider;
+    this.mergeOpProvider = mergeOpProvider;
+    this.allowedFormats = allowedFormats;
+  }
+
+  @Override
+  public BinaryResult apply(RevisionResource rsrc) throws RestApiException {
+    if (Strings.isNullOrEmpty(format)) {
+      throw new BadRequestException("format is not specified");
+    }
+    ArchiveFormat f = allowedFormats.extensions.get("." + format);
+    if (f == null && format.equals("tgz")) {
+      // Always allow tgz, even when the allowedFormats doesn't contain it.
+      // Then we allow at least one format even if the list of allowed
+      // formats is empty.
+      f = ArchiveFormat.TGZ;
+    }
+    if (f == null) {
+      throw new BadRequestException("unknown archive format");
+    }
+
+    Change change = rsrc.getChange();
+    if (!change.getStatus().isOpen()) {
+      throw new PreconditionFailedException("change is " + Submit.status(change));
+    }
+    ChangeControl control = rsrc.getControl();
+    if (!control.getUser().isIdentifiedUser()) {
+      throw new MethodNotAllowedException("Anonymous users cannot submit");
+    }
+    try (BinaryResult b = getBundles(rsrc, f)) {
+      b.disableGzip()
+          .setContentType(f.getMimeType())
+          .setAttachmentName("submit-preview-"
+              + change.getChangeId() + "." + format);
+      return b;
+    } catch (OrmException | IOException e) {
+      throw new RestApiException("Error generating submit preview");
+    }
+  }
+
+  private BinaryResult getBundles(RevisionResource rsrc, final ArchiveFormat f)
+      throws OrmException, RestApiException {
+    ReviewDb db = dbProvider.get();
+    ChangeControl control = rsrc.getControl();
+    IdentifiedUser caller = control.getUser().asIdentifiedUser();
+    Change change = rsrc.getChange();
+
+    BinaryResult bin;
+    try (MergeOp op = mergeOpProvider.get()) {
+      op.merge(db, change, caller, false, new SubmitInput(), true);
+      final MergeOpRepoManager orm = op.getMergeOpRepoManager();
+      final Set<Project.NameKey> projects = op.getAllProjects();
+
+      bin = new BinaryResult() {
+        @Override
+        public void writeTo(OutputStream out) throws IOException {
+          ArchiveOutputStream aos = f.createArchiveOutputStream(out);
+
+          for (Project.NameKey p : projects) {
+            OpenRepo or = orm.getRepo(p);
+            BundleWriter bw = new BundleWriter(or.getRepo());
+            bw.setObjectCountCallback(null);
+            bw.setPackConfig(null);
+            Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates();
+            for (ReceiveCommand r : refs) {
+              bw.include(r.getRefName(), r.getNewId());
+              if (!r.getOldId().equals(ObjectId.zeroId())) {
+                bw.assume(or.getCodeReviewRevWalk().parseCommit(r.getOldId()));
+              }
+            }
+            // This naming scheme cannot produce directory/file conflicts
+            // as no projects contains ".git/":
+            aos.putArchiveEntry(f.prepareArchiveEntry(p.get() + ".git"));
+            bw.writeBundle(NullProgressMonitor.INSTANCE, aos);
+            aos.closeArchiveEntry();
+          }
+          aos.finish();
+        }
+      };
+    }
+    return bin;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
index c86e98f..6875287 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -36,6 +36,7 @@
 import com.google.inject.Singleton;
 
 import java.io.IOException;
+import java.util.Optional;
 
 @Singleton
 public class PublishChangeEdit implements
@@ -71,9 +72,8 @@
   }
 
   @Singleton
-  public static class Publish implements RestModifyView<ChangeResource, Publish.Input> {
-    public static class Input {
-    }
+  public static class Publish
+      implements RestModifyView<ChangeResource, PublishChangeEditInput> {
 
     private final ChangeEditUtil editUtil;
 
@@ -83,7 +83,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, Publish.Input in)
+    public Response<?> apply(ChangeResource rsrc, PublishChangeEditInput in)
         throws NoSuchChangeException, IOException, OrmException,
         RestApiException, UpdateException {
       Capable r =
@@ -98,7 +98,10 @@
             "no edit exists for change %s",
             rsrc.getChange().getChangeId()));
       }
-      editUtil.publish(edit.get());
+      if (in == null) {
+        in = new PublishChangeEditInput();
+      }
+      editUtil.publish(edit.get(), in.notify);
       return Response.none();
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
index d17d69b..ba27005 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
@@ -34,7 +34,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
@@ -44,9 +44,9 @@
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.mail.CreateChangeSender;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gwtorm.server.OrmException;
@@ -252,15 +252,13 @@
 
     private void sendReplacePatchSet(Context ctx)
         throws EmailException, OrmException {
-      Account.Id accountId = ctx.getAccountId();
-      ChangeMessage msg =
-          new ChangeMessage(new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())), accountId,
-              ctx.getWhen(), psId);
-      msg.setMessage("Uploaded patch set " + psId.get() + ".");
+      ChangeMessage msg = ChangeMessagesUtil.newMessage(
+          ctx.getDb(), psId, ctx.getUser(), ctx.getWhen(),
+          "Uploaded patch set " + psId.get() + ".",
+          ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
       ReplacePatchSetSender cm =
           replacePatchSetFactory.create(ctx.getProject(), change.getId());
-      cm.setFrom(accountId);
+      cm.setFrom(ctx.getAccountId());
       cm.setPatchSet(patchSet, patchSetInfo);
       cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
       cm.addReviewers(recipients.getReviewers());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
new file mode 100644
index 0000000..5002436
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountJson;
+import com.google.gerrit.server.change.PostReviewers.Addition;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.io.IOException;
+
+@Singleton
+public class PutAssignee implements
+    RestModifyView<ChangeResource, AssigneeInput>, UiAction<ChangeResource> {
+
+  private final SetAssigneeOp.Factory assigneeFactory;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final Provider<ReviewDb> db;
+  private final PostReviewers postReviewers;
+
+  @Inject
+  PutAssignee(SetAssigneeOp.Factory assigneeFactory,
+      BatchUpdate.Factory batchUpdateFactory,
+      Provider<ReviewDb> db,
+      PostReviewers postReviewers) {
+    this.assigneeFactory = assigneeFactory;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.db = db;
+    this.postReviewers = postReviewers;
+  }
+
+  @Override
+  public Response<AccountInfo> apply(ChangeResource rsrc, AssigneeInput input)
+      throws RestApiException, UpdateException, OrmException, IOException {
+    if (!rsrc.getControl().canEditAssignee()) {
+      throw new AuthException("Changing Assignee not permitted");
+    }
+    if (Strings.isNullOrEmpty(input.assignee)) {
+      throw new BadRequestException("missing assignee field");
+    }
+
+    try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
+        rsrc.getChange().getProject(), rsrc.getControl().getUser(),
+        TimeUtil.nowTs())) {
+      SetAssigneeOp op = assigneeFactory.create(input.assignee);
+      bu.addOp(rsrc.getId(), op);
+
+      PostReviewers.Addition reviewersAddition =
+          addAssigneeAsCC(rsrc, input.assignee);
+      bu.addOp(rsrc.getId(), reviewersAddition.op);
+
+      bu.execute();
+      return Response.ok(AccountJson.toAccountInfo(op.getNewAssignee()));
+    }
+  }
+
+  private Addition addAssigneeAsCC(ChangeResource rsrc, String assignee)
+      throws OrmException, RestApiException, IOException {
+    AddReviewerInput reviewerInput = new AddReviewerInput();
+    reviewerInput.reviewer = assignee;
+    reviewerInput.state = ReviewerState.CC;
+    reviewerInput.confirmed = true;
+    reviewerInput.notify = NotifyHandling.NONE;
+    return postReviewers.prepareApplication(rsrc, reviewerInput);
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource resource) {
+    return new UiAction.Description()
+      .setLabel("Edit Assignee")
+      .setVisible(resource.getControl().canEditAssignee());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
index 655e07d..0742c6d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.client.Comment;
-import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -28,11 +25,11 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -46,13 +43,14 @@
 
 import java.sql.Timestamp;
 import java.util.Collections;
+import java.util.Optional;
 
 @Singleton
 public class PutDraftComment implements RestModifyView<DraftCommentResource, DraftInput> {
 
   private final Provider<ReviewDb> db;
   private final DeleteDraftComment delete;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
@@ -61,14 +59,14 @@
   @Inject
   PutDraftComment(Provider<ReviewDb> db,
       DeleteDraftComment delete,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
       PatchListCache patchListCache) {
     this.db = db;
     this.delete = delete;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
@@ -91,21 +89,22 @@
     try (BatchUpdate bu = updateFactory.create(
         db.get(), rsrc.getChange().getProject(), rsrc.getControl().getUser(),
         TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getComment().getKey(), in);
+      Op op = new Op(rsrc.getComment().key, in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
-      return Response.ok(
-          commentJson.get().setFillAccounts(false).format(op.comment));
+      return Response.ok(commentJson.get()
+          .setFillAccounts(false)
+          .newCommentFormatter().format(op.comment));
     }
   }
 
   private class Op extends BatchUpdate.Op {
-    private final PatchLineComment.Key key;
+    private final Comment.Key key;
     private final DraftInput in;
 
-    private PatchLineComment comment;
+    private Comment comment;
 
-    private Op(PatchLineComment.Key key, DraftInput in) {
+    private Op(Comment.Key key, DraftInput in) {
       this.key = key;
       this.in = in;
     }
@@ -113,17 +112,21 @@
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws ResourceNotFoundException, OrmException {
-      Optional<PatchLineComment> maybeComment =
-          plcUtil.get(ctx.getDb(), ctx.getNotes(), key);
+      Optional<Comment> maybeComment =
+          commentsUtil.get(ctx.getDb(), ctx.getNotes(), key);
       if (!maybeComment.isPresent()) {
         // Disappeared out from under us. Can't easily fall back to insert,
         // because the input might be missing required fields. Just give up.
         throw new ResourceNotFoundException("comment not found: " + key);
       }
-      PatchLineComment origComment = maybeComment.get();
-      comment = new PatchLineComment(origComment);
+      Comment origComment = maybeComment.get();
+      comment = new Comment(origComment);
+      // Copy constructor preserved old real author; replace with current real
+      // user.
+      ctx.getUser().updateRealAccountId(comment::setRealAuthor);
 
-      PatchSet.Id psId = comment.getKey().getParentKey().getParentKey();
+      PatchSet.Id psId =
+          new PatchSet.Id(ctx.getChange().getId(), origComment.key.patchSetId);
       ChangeUpdate update = ctx.getUpdate(psId);
 
       PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
@@ -131,61 +134,36 @@
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
       if (in.path != null
-          && !in.path.equals(comment.getKey().getParentKey().getFileName())) {
+          && !in.path.equals(origComment.key.filename)) {
         // Updating the path alters the primary key, which isn't possible.
         // Delete then recreate the comment instead of an update.
 
-        plcUtil.deleteComments(
+        commentsUtil.deleteComments(
             ctx.getDb(), update, Collections.singleton(origComment));
-        comment = new PatchLineComment(
-            new PatchLineComment.Key(
-                new Patch.Key(psId, in.path),
-                comment.getKey().get()),
-            comment.getLine(),
-            ctx.getAccountId(),
-            comment.getParentUuid(), ctx.getWhen());
-        comment.setTag(origComment.getTag());
-        setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
-        plcUtil.putComments(ctx.getDb(), update,
-            Collections.singleton(update(comment, in, ctx.getWhen())));
-      } else {
-        if (comment.getRevId() == null) {
-          setCommentRevId(
-              comment, patchListCache, ctx.getChange(), ps);
-        }
-        plcUtil.putComments(ctx.getDb(), update,
-            Collections.singleton(update(comment, in, ctx.getWhen())));
+        comment.key.filename = in.path;
       }
+      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+      commentsUtil.putComments(ctx.getDb(), update, Status.DRAFT,
+          Collections.singleton(update(comment, in, ctx.getWhen())));
       ctx.bumpLastUpdatedOn(false);
       return true;
     }
   }
 
-  private static PatchLineComment update(PatchLineComment e, DraftInput in,
-      Timestamp when) {
+  private static Comment update(Comment e, DraftInput in, Timestamp when) {
     if (in.side != null) {
-      e.setSide(side(in));
+      e.side = in.side();
     }
     if (in.inReplyTo != null) {
-      e.setParentUuid(Url.decode(in.inReplyTo));
+      e.parentUuid = Url.decode(in.inReplyTo);
     }
-    e.setMessage(in.message.trim());
-    if (in.range != null || in.line != null) {
-      e.setRange(in.range);
-      e.setLine(in.range != null ? in.range.endLine : in.line);
-    }
-    e.setWrittenOn(when);
+    e.setLineNbrAndRange(in.line, in.range);
+    e.message = in.message.trim();
+    e.writtenOn = when;
     if (in.tag != null) {
       // TODO(dborowitz): Can we support changing tags via PUT?
-      e.setTag(in.tag);
+      e.tag = in.tag;
     }
     return e;
   }
-
-  static short side(Comment c) {
-    if (c.side == Side.PARENT) {
-      return (short) (c.parent == null ? 0 : -c.parent.shortValue());
-    }
-    return 1;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
index 31ae892..62ef261 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.PutTopic.Input;
 import com.google.gerrit.server.extensions.events.TopicEdited;
 import com.google.gerrit.server.git.BatchUpdate;
@@ -115,13 +114,8 @@
       change.setTopic(Strings.emptyToNull(newTopicName));
       update.setTopic(change.getTopic());
 
-      ChangeMessage cmsg = new ChangeMessage(
-          new ChangeMessage.Key(
-              change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          ctx.getAccountId(), ctx.getWhen(),
-          change.currentPatchSetId());
-      cmsg.setMessage(summary);
+      ChangeMessage cmsg = ChangeMessagesUtil.newMessage(ctx, summary,
+          ChangeMessagesUtil.TAG_SET_TOPIC);
       cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
       return true;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
index baa0990..ac1b770 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -39,6 +38,7 @@
 import com.google.inject.Singleton;
 
 import java.io.IOException;
+import java.util.Optional;
 
 @Singleton
 public class RebaseChangeEdit implements
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 8909e60..d39f4fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -67,6 +68,7 @@
   private CommitValidators.Policy validate;
   private boolean forceContentMerge;
   private boolean copyApprovals = true;
+  private boolean postMessage = true;
 
   private RevCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
@@ -116,6 +118,11 @@
     return this;
   }
 
+  public RebaseChangeOp setPostMessage(boolean postMessage) {
+    this.postMessage = postMessage;
+    return this;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx) throws MergeConflictException,
        InvalidChangeOperationException, RestApiException, IOException,
@@ -150,12 +157,13 @@
     patchSetInserter = patchSetInserterFactory
         .create(ctl, rebasedPatchSetId, rebasedCommit)
         .setDraft(originalPatchSet.isDraft())
-        .setSendMail(false)
+        .setNotify(NotifyHandling.NONE)
         .setFireRevisionCreated(fireRevisionCreated)
-        .setCopyApprovals(copyApprovals)
-        .setMessage(
-          "Patch Set " + rebasedPatchSetId.get()
+        .setCopyApprovals(copyApprovals);
+    if (postMessage) {
+      patchSetInserter.setMessage("Patch Set " + rebasedPatchSetId.get()
           + ": Patch Set " + originalPatchSet.getId().get() + " was rebased");
+    }
 
     if (base != null) {
       patchSetInserter.setGroups(base.patchSet().getGroups());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
index 5fe0e0b..39820b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
@@ -14,14 +14,21 @@
 
 package com.google.gerrit.server.change;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.Rebuild.Input;
-import com.google.gerrit.server.notedb.ChangeRebuilder;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -31,6 +38,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 import java.io.IOException;
+import java.util.List;
 
 @Singleton
 public class Rebuild implements RestModifyView<ChangeResource, Input> {
@@ -40,29 +48,63 @@
   private final Provider<ReviewDb> db;
   private final NotesMigration migration;
   private final ChangeRebuilder rebuilder;
+  private final ChangeBundleReader bundleReader;
+  private final CommentsUtil commentsUtil;
+  private final ChangeNotes.Factory notesFactory;
 
   @Inject
   Rebuild(Provider<ReviewDb> db,
       NotesMigration migration,
-      ChangeRebuilder rebuilder) {
+      ChangeRebuilder rebuilder,
+      ChangeBundleReader bundleReader,
+      CommentsUtil commentsUtil,
+      ChangeNotes.Factory notesFactory) {
     this.db = db;
     this.migration = migration;
     this.rebuilder = rebuilder;
+    this.bundleReader = bundleReader;
+    this.commentsUtil = commentsUtil;
+    this.notesFactory = notesFactory;
   }
 
   @Override
-  public Response<?> apply(ChangeResource rsrc, Input input)
+  public BinaryResult apply(ChangeResource rsrc, Input input)
       throws ResourceNotFoundException, IOException, OrmException,
       ConfigInvalidException {
     if (!migration.commitChangeWrites()) {
       throw new ResourceNotFoundException();
+    } if (!migration.readChanges()) {
+      // ChangeBundle#fromNotes currently doesn't work if reading isn't enabled,
+      // so don't attempt a diff.
+      rebuild(rsrc);
+      return BinaryResult.create("Rebuilt change successfully");
     }
+
+    // Not the same transaction as the rebuild, so may result in spurious diffs
+    // in the case of races. This should be easy enough to detect by rerunning.
+    ChangeBundle reviewDbBundle = bundleReader.fromReviewDb(
+        ReviewDbUtil.unwrapDb(db.get()), rsrc.getId());
+    rebuild(rsrc);
+    ChangeNotes notes = notesFactory.create(
+        db.get(), rsrc.getChange().getProject(), rsrc.getId());
+    ChangeBundle noteDbBundle = ChangeBundle.fromNotes(commentsUtil, notes);
+    List<String> diffs = reviewDbBundle.differencesFrom(noteDbBundle);
+    if (diffs.isEmpty()) {
+      return BinaryResult.create("No differences between ReviewDb and NoteDb");
+    }
+    return BinaryResult.create(
+        diffs.stream()
+            .collect(joining(
+                "\n", "Differences between ReviewDb and NoteDb:\n", "\n")));
+  }
+
+  private void rebuild(ChangeResource rsrc) throws ResourceNotFoundException,
+      ConfigInvalidException, OrmException, IOException {
     try {
       rebuilder.rebuild(db.get(), rsrc.getId());
     } catch (NoSuchChangeException e) {
       throw new ResourceNotFoundException(
           IdString.fromDecoded(rsrc.getId().toString()));
     }
-    return Response.none();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
index 9c4c6d9..a1972f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
@@ -29,15 +29,14 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.mail.ReplyToChangeSender;
-import com.google.gerrit.server.mail.RestoredSender;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
@@ -131,16 +130,8 @@
         msg.append("\n\n");
         msg.append(input.message.trim());
       }
-
-      ChangeMessage message = new ChangeMessage(
-          new ChangeMessage.Key(
-              change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          ctx.getAccountId(),
-          ctx.getWhen(),
-          change.currentPatchSetId());
-      message.setMessage(msg.toString());
-      return message;
+      return ChangeMessagesUtil.newMessage(ctx, msg.toString(),
+          ChangeMessagesUtil.TAG_RESTORE);
     }
 
     @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
index 3ca496a..ab318c1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
@@ -46,7 +45,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.RevertedSender;
+import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectControl;
@@ -274,14 +273,9 @@
     public boolean updateChange(ChangeContext ctx) throws Exception {
       Change change = ctx.getChange();
       PatchSet.Id patchSetId = change.currentPatchSetId();
-      ChangeMessage changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(db.get())),
-          ctx.getAccountId(), ctx.getWhen(), patchSetId);
-      StringBuilder msgBuf = new StringBuilder();
-      msgBuf.append("Created a revert of this change as ")
-          .append("I").append(computedChangeId.name());
-      changeMessage.setMessage(msgBuf.toString());
+      ChangeMessage changeMessage = ChangeMessagesUtil.newMessage(ctx,
+          "Created a revert of this change as I" + computedChangeId.name(),
+          ChangeMessagesUtil.TAG_REVERT);
       cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(patchSetId),
           changeMessage);
       return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestion.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestion.java
new file mode 100644
index 0000000..6affd9f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestion.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+
+import java.util.Set;
+
+/**
+ * Listener to provide reviewer suggestions.
+ * <p>
+ * Invoked by Gerrit a user who is searching for a reviewer to add to a change.
+ */
+@ExtensionPoint
+public interface ReviewerSuggestion {
+  /**
+   * Reviewer suggestion.
+   *
+   * @param project The name key of the project the suggestion is for.
+   * @param changeId The changeId that the suggestion is for. Can be an {@code null}.
+   * @param query The query as typed by the user. Can be an {@code null}.
+   * @param candidates A set of candidates for the ranking. Can be empty.
+   * @return Set of suggested reviewers as a tuple of account id and score.
+   *         The account ids listed here don't have to be a part of candidates.
+   */
+  Set<SuggestedReviewer> suggestReviewers(Project.NameKey project,
+      @Nullable Change.Id changeId, @Nullable String query,
+      Set<Account.Id> candidates);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
index a8fd013..152563b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -28,6 +27,8 @@
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
 
+import java.util.Optional;
+
 public class RevisionResource implements RestResource, HasETag {
   public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
       new TypeLiteral<RestView<RevisionResource>>() {};
@@ -38,7 +39,7 @@
   private boolean cacheable = true;
 
   public RevisionResource(ChangeResource change, PatchSet ps) {
-    this(change, ps, Optional.<ChangeEdit> absent());
+    this(change, ps, Optional.empty());
   }
 
   public RevisionResource(ChangeResource change, PatchSet ps,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
index 30a09cf..4572994 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -38,6 +37,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 
 @Singleton
 public class Revisions implements ChildCollection<ChangeResource, RevisionResource> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java
new file mode 100644
index 0000000..856c777
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.inject.TypeLiteral;
+
+public class RobotCommentResource implements RestResource {
+  public static final TypeLiteral<RestView<RobotCommentResource>> ROBOT_COMMENT_KIND =
+      new TypeLiteral<RestView<RobotCommentResource>>() {};
+
+  private final RevisionResource rev;
+  private final RobotComment comment;
+
+  public RobotCommentResource(RevisionResource rev, RobotComment c) {
+    this.rev = rev;
+    this.comment = c;
+  }
+
+  public PatchSet getPatchSet() {
+    return rev.getPatchSet();
+  }
+
+  RobotComment getComment() {
+    return comment;
+  }
+
+  String getId() {
+    return comment.key.uuid;
+  }
+
+  Account.Id getAuthorId() {
+    return comment.author.getId();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java
new file mode 100644
index 0000000..886af1d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class RobotComments
+    implements ChildCollection<RevisionResource, RobotCommentResource> {
+  private final DynamicMap<RestView<RobotCommentResource>> views;
+  private final ListRobotComments list;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  RobotComments(DynamicMap<RestView<RobotCommentResource>> views,
+      ListRobotComments list,
+      CommentsUtil commentsUtil) {
+    this.views = views;
+    this.list = list;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public DynamicMap<RestView<RobotCommentResource>> views() {
+    return views;
+  }
+
+  @Override
+  public ListRobotComments list() {
+    return list;
+  }
+
+  @Override
+  public RobotCommentResource parse(RevisionResource rev, IdString id)
+      throws ResourceNotFoundException, OrmException {
+    String uuid = id.get();
+    ChangeNotes notes = rev.getNotes();
+
+    for (RobotComment c : commentsUtil.robotCommentsByPatchSet(
+        notes, rev.getPatchSet().getId())) {
+      if (uuid.equals(c.key.uuid)) {
+        return new RobotCommentResource(rev, c);
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
new file mode 100644
index 0000000..44275ab
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountInfoCacheFactory;
+import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.extensions.events.AssigneeChanged;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.validators.AssigneeValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import java.util.Optional;
+
+public class SetAssigneeOp extends BatchUpdate.Op {
+  public interface Factory {
+    SetAssigneeOp create(String assignee);
+  }
+
+  private final AccountsCollection accounts;
+  private final ChangeMessagesUtil cmUtil;
+  private final AccountInfoCacheFactory.Factory accountInfosFactory;
+  private final DynamicSet<AssigneeValidationListener> validationListeners;
+  private final String assignee;
+  private final String anonymousCowardName;
+  private final AssigneeChanged assigneeChanged;
+
+  private Change change;
+  private Account newAssignee;
+  private Account oldAssignee;
+
+  @AssistedInject
+  SetAssigneeOp(AccountsCollection accounts,
+      ChangeMessagesUtil cmUtil,
+      AccountInfoCacheFactory.Factory accountInfosFactory,
+      DynamicSet<AssigneeValidationListener> validationListeners,
+      AssigneeChanged assigneeChanged,
+      @AnonymousCowardName String anonymousCowardName,
+      @Assisted String assignee) {
+    this.accounts = accounts;
+    this.cmUtil = cmUtil;
+    this.accountInfosFactory = accountInfosFactory;
+    this.validationListeners = validationListeners;
+    this.assigneeChanged = assigneeChanged;
+    this.anonymousCowardName = anonymousCowardName;
+    this.assignee = checkNotNull(assignee);
+  }
+
+  @Override
+  public boolean updateChange(BatchUpdate.ChangeContext ctx)
+      throws OrmException, RestApiException {
+    change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    Optional<Account.Id> oldAssigneeId =
+        Optional.ofNullable(change.getAssignee());
+    oldAssignee = null;
+    if (oldAssigneeId.isPresent()) {
+      oldAssignee = accountInfosFactory.create().get(oldAssigneeId.get());
+    }
+    IdentifiedUser newAssigneeUser = accounts.parse(assignee);
+    if (oldAssigneeId.isPresent() &&
+        oldAssigneeId.get().equals(newAssigneeUser.getAccountId())) {
+      newAssignee = oldAssignee;
+      return false;
+    }
+    if (!newAssigneeUser.getAccount().isActive()) {
+      throw new UnprocessableEntityException(String.format(
+          "Account of %s is not active", assignee));
+    }
+    if (!ctx.getControl().forUser(newAssigneeUser).isRefVisible()) {
+      throw new AuthException(String.format(
+          "Change %s is not visible to %s.",
+          change.getChangeId(),
+          assignee));
+    }
+    try {
+      for (AssigneeValidationListener validator : validationListeners) {
+        validator.validateAssignee(change, newAssigneeUser.getAccount());
+      }
+    } catch (ValidationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+    // notedb
+    update.setAssignee(newAssigneeUser.getAccountId());
+    // reviewdb
+    change.setAssignee(newAssigneeUser.getAccountId());
+    this.newAssignee = newAssigneeUser.getAccount();
+    addMessage(ctx, update, oldAssignee);
+    return true;
+  }
+
+  private void addMessage(BatchUpdate.ChangeContext ctx, ChangeUpdate update,
+      Account previousAssignee) throws OrmException {
+    StringBuilder msg = new StringBuilder();
+    msg.append("Assignee ");
+    if (previousAssignee == null) {
+      msg.append("added: ");
+      msg.append(newAssignee.getName(anonymousCowardName));
+    } else {
+      msg.append("changed from: ");
+      msg.append(previousAssignee.getName(anonymousCowardName));
+      msg.append(" to: ");
+      msg.append(newAssignee.getName(anonymousCowardName));
+    }
+    ChangeMessage cmsg = ChangeMessagesUtil.newMessage(ctx, msg.toString(),
+        ChangeMessagesUtil.TAG_SET_ASSIGNEE);
+    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws OrmException {
+    assigneeChanged.fire(change, ctx.getAccount(), oldAssignee, ctx.getWhen());
+  }
+
+  public Account getNewAssignee() {
+    return newAssignee;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
index 50f6e74..3b2117d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.extensions.events.HashtagsEdited;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -86,9 +86,11 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws AuthException, BadRequestException, OrmException, IOException {
+      throws AuthException, BadRequestException, MethodNotAllowedException,
+      OrmException, IOException {
     if (!notesMigration.readChanges()) {
-      throw new BadRequestException("Cannot add hashtags; NoteDb is disabled");
+      throw new MethodNotAllowedException(
+          "Cannot add hashtags; NoteDb is disabled");
     }
     if (input == null
         || (input.add == null && input.remove == null)) {
@@ -129,18 +131,13 @@
     return true;
   }
 
-  private void addMessage(Context ctx, ChangeUpdate update)
+  private void addMessage(ChangeContext ctx, ChangeUpdate update)
       throws OrmException {
     StringBuilder msg = new StringBuilder();
     appendHashtagMessage(msg, "added", toAdd);
     appendHashtagMessage(msg, "removed", toRemove);
-    ChangeMessage cmsg = new ChangeMessage(
-        new ChangeMessage.Key(
-            change.getId(),
-            ChangeUtil.messageUUID(ctx.getDb())),
-        ctx.getAccountId(), ctx.getWhen(),
-        change.currentPatchSetId());
-    cmsg.setMessage(msg.toString());
+    ChangeMessage cmsg = ChangeMessagesUtil.newMessage(ctx, msg.toString(),
+        ChangeMessagesUtil.TAG_SET_HASHTAGS);
     cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
index 4750197..e80e758 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
@@ -14,15 +14,13 @@
 
 package com.google.gerrit.server.change;
 
+import static java.util.stream.Collectors.joining;
+
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Predicate;
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.ParameterizedString;
@@ -134,7 +132,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final Provider<MergeOp> mergeOpProvider;
-  private final MergeSuperSet mergeSuperSet;
+  private final Provider<MergeSuperSet> mergeSuperSet;
   private final AccountsCollection accounts;
   private final ChangesCollection changes;
   private final String label;
@@ -154,7 +152,7 @@
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory changeNotesFactory,
       Provider<MergeOp> mergeOpProvider,
-      MergeSuperSet mergeSuperSet,
+      Provider<MergeSuperSet> mergeSuperSet,
       AccountsCollection accounts,
       ChangesCollection changes,
       @GerritServerConfig Config cfg,
@@ -222,7 +220,7 @@
 
     try (MergeOp op = mergeOpProvider.get()) {
       ReviewDb db = dbProvider.get();
-      op.merge(db, change, caller, true, input);
+      op.merge(db, change, caller, true, input, false);
       try {
         change = changeNotesFactory
             .createChecked(db, change.getProject(), change.getId()).getChange();
@@ -282,14 +280,10 @@
             return CHANGE_UNMERGEABLE;
           }
         }
-        return CHANGES_NOT_MERGEABLE + Joiner.on(", ").join(
-            Iterables.transform(unmergeable,
-                new Function<ChangeData, String>() {
-              @Override
-              public String apply(ChangeData cd) {
-                return String.valueOf(cd.getId().get());
-              }
-            }));
+        return CHANGES_NOT_MERGEABLE +
+            unmergeable.stream()
+                .map(c -> c.getId().toString())
+                .collect(joining(", "));
       }
     } catch (ResourceConflictException e) {
       return BLOCKED_SUBMIT_TOOLTIP;
@@ -300,22 +294,6 @@
     return null;
   }
 
-  /**
-   * Check if there are any problems with the given change. It doesn't take
-   * any problems of related changes into account.
-   * <p>
-   * @param cd the change to check for submittability
-   * @return if the change has any problems for submission
-   */
-  public static boolean submittable(ChangeData cd) {
-    try {
-      MergeOp.checkSubmitRule(cd);
-      return true;
-    } catch (ResourceConflictException | OrmException e) {
-      return false;
-    }
-  }
-
   @Override
   public UiAction.Description getDescription(RevisionResource resource) {
     PatchSet.Id current = resource.getChange().currentPatchSetId();
@@ -345,7 +323,7 @@
 
     ChangeSet cs;
     try {
-      cs = mergeSuperSet.completeChangeSet(
+      cs = mergeSuperSet.get().completeChangeSet(
           db, cd.change(), resource.getControl().getUser());
     } catch (OrmException | IOException e) {
       throw new OrmRuntimeException("Could not determine complete set of " +
@@ -421,14 +399,10 @@
    */
   public ChangeMessage getConflictMessage(RevisionResource rsrc)
       throws OrmException {
-    return FluentIterable.from(cmUtil.byPatchSet(dbProvider.get(), rsrc.getNotes(),
-        rsrc.getPatchSet().getId()))
-        .filter(new Predicate<ChangeMessage>() {
-          @Override
-          public boolean apply(ChangeMessage input) {
-            return input.getAuthor() == null;
-          }
-        })
+    return FluentIterable.from(
+            cmUtil.byPatchSet(
+                dbProvider.get(), rsrc.getNotes(), rsrc.getPatchSet().getId()))
+        .filter(cm -> cm.getAuthor() == null)
         .last()
         .orNull();
   }
@@ -511,16 +485,12 @@
     if (!caller.canSubmitAs()) {
       throw new AuthException("submit on behalf of not permitted");
     }
-    IdentifiedUser targetUser = accounts.parseId(in.onBehalfOf);
-    if (targetUser == null) {
-      throw new UnprocessableEntityException(String.format(
-          "Account Not Found: %s", in.onBehalfOf));
-    }
-    ChangeControl target = caller.forUser(targetUser);
+    ChangeControl target = caller.forUser(
+        accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
     if (!target.getRefControl().isVisible()) {
       throw new UnprocessableEntityException(String.format(
           "on_behalf_of account %s cannot see destination ref",
-          targetUser.getAccountId()));
+          target.getUser().getAccountId()));
     }
     return new RevisionResource(changes.parse(target), rsrc.getPatchSet());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
index c4c0e98..23e7d8b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -32,7 +34,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 
 import org.kohsuke.args4j.Option;
 import org.slf4j.Logger;
@@ -44,29 +45,48 @@
 import java.util.EnumSet;
 import java.util.List;
 
-@Singleton
 public class SubmittedTogether implements RestReadView<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(
       SubmittedTogether.class);
 
   private final EnumSet<SubmittedTogetherOption> options =
       EnumSet.noneOf(SubmittedTogetherOption.class);
+
+  private final EnumSet<ListChangesOption> jsonOpt = EnumSet.of(
+      ListChangesOption.CURRENT_REVISION,
+      ListChangesOption.CURRENT_COMMIT,
+      ListChangesOption.SUBMITTABLE);
+
   private final ChangeJson.Factory json;
   private final Provider<ReviewDb> dbProvider;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final MergeSuperSet mergeSuperSet;
+  private final Provider<MergeSuperSet> mergeSuperSet;
   private final Provider<WalkSorter> sorter;
 
   @Option(name = "-o", usage = "Output options")
-  void addOption(SubmittedTogetherOption o) {
-    options.add(o);
+  void addOption(String option) {
+    for (ListChangesOption o : ListChangesOption.values()) {
+      if (o.name().equalsIgnoreCase(option)) {
+        jsonOpt.add(o);
+        return;
+      }
+    }
+
+    for (SubmittedTogetherOption o : SubmittedTogetherOption.values()) {
+      if (o.name().equalsIgnoreCase(option)) {
+        options.add(o);
+        return;
+      }
+    }
+
+    throw new IllegalArgumentException("option not recognized: " + option);
   }
 
   @Inject
   SubmittedTogether(ChangeJson.Factory json,
       Provider<ReviewDb> dbProvider,
       Provider<InternalChangeQuery> queryProvider,
-      MergeSuperSet mergeSuperSet,
+      Provider<MergeSuperSet> mergeSuperSet,
       Provider<WalkSorter> sorter) {
     this.json = json;
     this.dbProvider = dbProvider;
@@ -75,19 +95,29 @@
     this.sorter = sorter;
   }
 
+  public SubmittedTogether addListChangesOption(EnumSet<ListChangesOption> o) {
+    jsonOpt.addAll(o);
+    return this;
+  }
+
+  public SubmittedTogether addSubmittedTogetherOption(
+      EnumSet<SubmittedTogetherOption> o) {
+    options.addAll(o);
+    return this;
+  }
+
   @Override
   public Object apply(ChangeResource resource)
       throws AuthException, BadRequestException,
       ResourceConflictException, IOException, OrmException {
-    SubmittedTogetherInfo info = apply(resource, options);
+    SubmittedTogetherInfo info = applyInfo(resource);
     if (options.isEmpty()) {
       return info.changes;
     }
     return info;
   }
 
-  public SubmittedTogetherInfo apply(ChangeResource resource,
-      EnumSet<SubmittedTogetherOption> options)
+  public SubmittedTogetherInfo applyInfo(ChangeResource resource)
       throws AuthException, IOException, OrmException {
     Change c = resource.getChange();
     try {
@@ -96,7 +126,7 @@
 
       if (c.getStatus().isOpen()) {
         ChangeSet cs =
-            mergeSuperSet.completeChangeSet(
+            mergeSuperSet.get().completeChangeSet(
                 dbProvider.get(), c, resource.getControl().getUser());
         cds = cs.changes().asList();
         hidden = cs.nonVisibleChanges().size();
@@ -109,7 +139,7 @@
       }
 
       if (hidden != 0
-          && !options.contains(SubmittedTogetherOption.NON_VISIBLE_CHANGES)) {
+          && !options.contains(NON_VISIBLE_CHANGES)) {
         throw new AuthException(
             "change would be submitted with a change that you cannot see");
       }
@@ -123,10 +153,7 @@
       }
 
       SubmittedTogetherInfo info = new SubmittedTogetherInfo();
-      info.changes = json.create(EnumSet.of(
-          ListChangesOption.CURRENT_REVISION,
-          ListChangesOption.CURRENT_COMMIT))
-        .formatChangeDatas(cds);
+      info.changes = json.create(jsonOpt).formatChangeDatas(cds);
       info.nonVisibleChanges = hidden;
       return info;
     } catch (OrmException | IOException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
index 02d3afe..131513b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
@@ -30,12 +30,18 @@
 import com.google.inject.Provider;
 
 import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
 
 import java.io.IOException;
 import java.util.List;
 
 public class SuggestChangeReviewers extends SuggestReviewers
     implements RestReadView<ChangeResource> {
+
+  @Option(name = "--exclude-groups", aliases = {"-e"},
+      usage = "exclude groups from query")
+  boolean excludeGroups;
+
   @Inject
   SuggestChangeReviewers(AccountVisibility av,
       GenericFactory identifiedUserFactory,
@@ -48,8 +54,8 @@
   @Override
   public List<SuggestedReviewerInfo> apply(ChangeResource rsrc)
       throws BadRequestException, OrmException, IOException {
-    return reviewersUtil.suggestReviewers(this,
-        rsrc.getControl().getProjectControl(), getVisibility(rsrc));
+    return reviewersUtil.suggestReviewers(rsrc.getNotes(), this,
+        rsrc.getControl().getProjectControl(), getVisibility(rsrc), excludeGroups);
   }
 
   private VisibilityControl getVisibility(final ChangeResource rsrc) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
index f159c69..2af1f6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
@@ -33,7 +33,6 @@
   protected final ReviewersUtil reviewersUtil;
 
   private final boolean suggestAccounts;
-  private final int suggestFrom;
   private final int maxAllowed;
   private final int maxAllowedWithoutConfirmation;
   protected int limit;
@@ -62,10 +61,6 @@
     return suggestAccounts;
   }
 
-  public int getSuggestFrom() {
-    return suggestFrom;
-  }
-
   public int getLimit() {
     return limit;
   }
@@ -98,7 +93,6 @@
       this.suggestAccounts = (av != AccountVisibility.NONE);
     }
 
-    this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
     this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed",
         PostReviewers.DEFAULT_MAX_REVIEWERS);
     this.maxAllowedWithoutConfirmation = cfg.getInt(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestedReviewer.java
similarity index 69%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestedReviewer.java
index 9bcabc3..353bf3b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestedReviewer.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -11,14 +11,12 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.change;
 
 import com.google.gerrit.reviewdb.client.Account;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
+public class SuggestedReviewer {
 
-  Address from(Account.Id fromId);
+  public Account.Id account;
+  public double score;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java
index d31805d..e0959f5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java
@@ -18,7 +18,6 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -72,21 +71,17 @@
       LoggerFactory.getLogger(WalkSorter.class);
 
   private static final Ordering<List<PatchSetData>> PROJECT_LIST_SORTER =
-      Ordering.natural().nullsFirst()
-          .onResultOf(
-            new Function<List<PatchSetData>, Project.NameKey>() {
-              @Override
-              public Project.NameKey apply(List<PatchSetData> in) {
-                if (in == null || in.isEmpty()) {
-                  return null;
-                }
-                try {
-                  return in.get(0).data().change().getProject();
-                } catch (OrmException e) {
-                  throw new IllegalStateException(e);
-                }
-              }
-            });
+      Ordering.natural().nullsFirst().onResultOf(
+          (List<PatchSetData> in) -> {
+            if (in == null || in.isEmpty()) {
+              return null;
+            }
+            try {
+              return in.get(0).data().change().getProject();
+            } catch (OrmException e) {
+              throw new IllegalStateException(e);
+            }
+          });
 
   private final GitRepositoryManager repoManager;
   private final Set<PatchSet.Id> includePatchSets;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AgreementJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AgreementJson.java
new file mode 100644
index 0000000..3ababbc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AgreementJson.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.AgreementInfo;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupJson;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AgreementJson {
+  private static final Logger log =
+      LoggerFactory.getLogger(AgreementJson.class);
+
+  private final Provider<CurrentUser> self;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final GroupControl.GenericFactory genericGroupControlFactory;
+  private final GroupJson groupJson;
+
+  @Inject
+  AgreementJson(Provider<CurrentUser> self,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      GroupControl.GenericFactory genericGroupControlFactory,
+      GroupJson groupJson) {
+    this.self = self;
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.genericGroupControlFactory = genericGroupControlFactory;
+    this.groupJson = groupJson;
+  }
+
+  public AgreementInfo format(ContributorAgreement ca) {
+    AgreementInfo info = new AgreementInfo();
+    info.name = ca.getName();
+    info.description = ca.getDescription();
+    info.url = ca.getAgreementUrl();
+    GroupReference autoVerifyGroup = ca.getAutoVerify();
+    if (autoVerifyGroup != null && self.get().isIdentifiedUser()) {
+      IdentifiedUser user =
+          identifiedUserFactory.create(self.get().getAccountId());
+      try {
+        GroupControl gc = genericGroupControlFactory.controlFor(
+            user, autoVerifyGroup.getUUID());
+        GroupResource group = new GroupResource(gc);
+        info.autoVerifyGroup = groupJson.format(group);
+      } catch (NoSuchGroupException | OrmException e) {
+        log.warn("autoverify group \"" + autoVerifyGroup.getName() +
+            "\" does not exist, referenced in CLA \"" + ca.getName() + "\"");
+      }
+    }
+    return info;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
index f2fc94e..11a34f7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.gwtjsonrpc.server.XsrfException;
@@ -62,6 +63,7 @@
   private final boolean cookieSecure;
   private final SignedToken emailReg;
   private final boolean allowRegisterNewEmail;
+  private GitBasicAuthPolicy gitBasicAuthPolicy;
 
   @Inject
   AuthConfig(@GerritServerConfig final Config cfg)
@@ -90,6 +92,7 @@
     trustContainerAuth = cfg.getBoolean("auth", "trustContainerAuth", false);
     enableRunAs = cfg.getBoolean("auth", null, "enableRunAs", true);
     gitBasicAuth = cfg.getBoolean("auth", "gitBasicAuth", false);
+    gitBasicAuthPolicy = getBasicAuthPolicy(cfg);
     useContributorAgreements =
         cfg.getBoolean("auth", "contributoragreements", false);
     userNameToLowerCase = cfg.getBoolean("auth", "userNameToLowerCase", false);
@@ -124,6 +127,12 @@
     return cfg.getEnum("auth", null, "type", AuthType.OPENID);
   }
 
+  private GitBasicAuthPolicy getBasicAuthPolicy(Config cfg) {
+    GitBasicAuthPolicy defaultAuthPolicy =
+        isLdapAuthType() ? GitBasicAuthPolicy.LDAP : GitBasicAuthPolicy.HTTP;
+    return cfg.getEnum("auth", null, "gitBasicAuthPolicy", defaultAuthPolicy);
+  }
+
   /** Type of user authentication used by this Gerrit server. */
   public AuthType getAuthType() {
     return authType;
@@ -218,6 +227,10 @@
     return gitBasicAuth;
   }
 
+  public GitBasicAuthPolicy getGitBasicAuthPolicy() {
+    return gitBasicAuthPolicy;
+  }
+
   /** Whether contributor agreements are used. */
   public boolean isUseContributorAgreements() {
     return useContributorAgreements;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java
index 8e181a9..5b0f73d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.account.DefaultRealm;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.auth.AuthBackend;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index da1f9a6..7e868f2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -30,6 +30,7 @@
 import com.google.gerrit.extensions.config.ExternalIncludedIn;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
+import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
@@ -99,6 +100,7 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
+import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -116,7 +118,6 @@
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.strategy.SubmitStrategy;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.git.validators.MergeValidationListener;
 import com.google.gerrit.server.git.validators.MergeValidators;
 import com.google.gerrit.server.git.validators.MergeValidators.ProjectConfigValidator;
@@ -127,17 +128,19 @@
 import com.google.gerrit.server.group.GroupInfoCache;
 import com.google.gerrit.server.group.GroupModule;
 import com.google.gerrit.server.index.change.ReindexAfterUpdate;
-import com.google.gerrit.server.mail.AddKeySender;
-import com.google.gerrit.server.mail.AddReviewerSender;
-import com.google.gerrit.server.mail.CreateChangeSender;
-import com.google.gerrit.server.mail.DeleteReviewerSender;
 import com.google.gerrit.server.mail.EmailModule;
-import com.google.gerrit.server.mail.FromAddressGenerator;
-import com.google.gerrit.server.mail.FromAddressGeneratorProvider;
-import com.google.gerrit.server.mail.MergedSender;
-import com.google.gerrit.server.mail.RegisterNewEmailSender;
-import com.google.gerrit.server.mail.ReplacePatchSetSender;
-import com.google.gerrit.server.mail.VelocityRuntimeProvider;
+import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.FromAddressGenerator;
+import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
+import com.google.gerrit.server.mail.send.MailSoyTofuProvider;
+import com.google.gerrit.server.mail.send.MailTemplates;
+import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.VelocityRuntimeProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
@@ -161,6 +164,7 @@
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.server.validators.AssigneeValidationListener;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
@@ -170,6 +174,7 @@
 import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
+import com.google.template.soy.tofu.SoyTofu;
 
 import org.apache.velocity.runtime.RuntimeInstance;
 import org.eclipse.jgit.lib.Config;
@@ -275,6 +280,9 @@
 
     bind(RuntimeInstance.class)
         .toProvider(VelocityRuntimeProvider.class);
+    bind(SoyTofu.class)
+        .annotatedWith(MailTemplates.class)
+        .toProvider(MailSoyTofuProvider.class);
     bind(FromAddressGenerator.class).toProvider(
         FromAddressGeneratorProvider.class).in(SINGLETON);
     bind(Boolean.class).annotatedWith(DisableReverseDnsLookup.class)
@@ -300,6 +308,7 @@
     DynamicSet.setOf(binder(), CacheRemovalListener.class);
     DynamicMap.mapOf(binder(), CapabilityDefinition.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
+    DynamicSet.setOf(binder(), AssigneeChangedListener.class);
     DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
     DynamicSet.setOf(binder(), CommentAddedListener.class);
     DynamicSet.setOf(binder(), DraftPublishedListener.class);
@@ -344,6 +353,7 @@
     DynamicMap.mapOf(binder(), DownloadScheme.class);
     DynamicMap.mapOf(binder(), DownloadCommand.class);
     DynamicMap.mapOf(binder(), CloneCommand.class);
+    DynamicMap.mapOf(binder(), ReviewerSuggestion.class);
     DynamicSet.setOf(binder(), ExternalIncludedIn.class);
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
@@ -357,6 +367,7 @@
     DynamicSet.setOf(binder(), AccountExternalIdCreator.class);
     DynamicSet.setOf(binder(), WebUiPlugin.class);
     DynamicItem.itemOf(binder(), AccountPatchReviewStore.class);
+    DynamicSet.setOf(binder(), AssigneeValidationListener.class);
 
     factory(UploadValidators.Factory.class);
     DynamicSet.setOf(binder(), UploadValidationListener.class);
@@ -366,7 +377,6 @@
 
     bind(AnonymousUser.class);
 
-    factory(CommitValidators.Factory.class);
     factory(RefOperationValidators.Factory.class);
     factory(MergeValidators.Factory.class);
     factory(ProjectConfigValidator.Factory.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritOptions.java
new file mode 100644
index 0000000..ab4b463
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritOptions.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.extensions.client.UiType;
+
+import org.eclipse.jgit.lib.Config;
+
+public class GerritOptions {
+  private final boolean headless;
+  private final boolean slave;
+  private final boolean enablePolyGerrit;
+  private final boolean enableGwtUi;
+  private final boolean forcePolyGerritDev;
+  private final UiType defaultUi;
+
+  public GerritOptions(Config cfg, boolean headless, boolean slave,
+      boolean forcePolyGerritDev) {
+    this.slave = slave;
+    this.enablePolyGerrit = forcePolyGerritDev
+        || cfg.getBoolean("gerrit", null, "enablePolyGerrit", true);
+    this.enableGwtUi = cfg.getBoolean("gerrit", null, "enableGwtUi", true);
+    this.forcePolyGerritDev = forcePolyGerritDev;
+    this.headless = headless || (!enableGwtUi && !enablePolyGerrit);
+
+    UiType defaultUi = enablePolyGerrit && !enableGwtUi
+        ? UiType.POLYGERRIT
+        : UiType.GWT;
+    String uiStr = firstNonNull(
+        cfg.getString("gerrit", null, "ui"),
+        defaultUi.name());
+    this.defaultUi = firstNonNull(UiType.parse(uiStr), UiType.NONE);
+
+    switch (defaultUi) {
+      case GWT:
+        checkArgument(enableGwtUi,
+            "gerrit.ui = %s but GWT UI is disabled", defaultUi);
+        break;
+      case POLYGERRIT:
+        checkArgument(enablePolyGerrit,
+            "gerrit.ui = %s but PolyGerrit is disabled", defaultUi);
+        break;
+      case NONE:
+      default:
+        throw new IllegalArgumentException("invalid gerrit.ui: " + uiStr);
+    }
+  }
+
+  public boolean headless() {
+    return headless;
+  }
+
+  public boolean enableGwtUi() {
+    return !headless && enableGwtUi;
+  }
+
+  public boolean enableMasterFeatures() {
+    return !slave;
+  }
+
+  public boolean enablePolyGerrit() {
+    return !headless && enablePolyGerrit;
+  }
+
+  public boolean forcePolyGerritDev() {
+    return !headless && forcePolyGerritDev;
+  }
+
+  public UiType defaultUi() {
+    return defaultUi;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
index 1dc910c..54fc3fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
@@ -14,12 +14,24 @@
 
 package com.google.gerrit.server.config;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.extensions.client.UiType;
+import com.google.gerrit.extensions.common.AuthInfo;
+import com.google.gerrit.extensions.common.ChangeConfigInfo;
+import com.google.gerrit.extensions.common.DownloadInfo;
+import com.google.gerrit.extensions.common.DownloadSchemeInfo;
+import com.google.gerrit.extensions.common.GerritInfo;
+import com.google.gerrit.extensions.common.PluginConfigInfo;
+import com.google.gerrit.extensions.common.ReceiveInfo;
+import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.common.SshdInfo;
+import com.google.gerrit.extensions.common.SuggestInfo;
+import com.google.gerrit.extensions.common.UserConfigInfo;
 import com.google.gerrit.extensions.config.CloneCommand;
 import com.google.gerrit.extensions.config.DownloadCommand;
 import com.google.gerrit.extensions.config.DownloadScheme;
@@ -28,25 +40,28 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.EnableSignedPush;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.avatar.AvatarProvider;
+import com.google.gerrit.server.change.AllowedFormats;
 import com.google.gerrit.server.change.ArchiveFormat;
-import com.google.gerrit.server.change.GetArchive;
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.lib.Config;
 
 import java.net.MalformedURLException;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 
 public class GetServerInfo implements RestReadView<ConfigResource> {
@@ -61,7 +76,7 @@
   private final DynamicMap<DownloadCommand> downloadCommands;
   private final DynamicMap<CloneCommand> cloneCommands;
   private final DynamicSet<WebUiPlugin> plugins;
-  private final GetArchive.AllowedFormats archiveFormats;
+  private final AllowedFormats archiveFormats;
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
   private final String anonymousCowardName;
@@ -69,6 +84,10 @@
   private final boolean enableSignedPush;
   private final QueryDocumentationExecutor docSearcher;
   private final NotesMigration migration;
+  private final ProjectCache projectCache;
+  private final AgreementJson agreementJson;
+  private final GerritOptions gerritOptions;
+  private final ChangeIndexCollection indexes;
 
   @Inject
   public GetServerInfo(
@@ -79,14 +98,18 @@
       DynamicMap<DownloadCommand> downloadCommands,
       DynamicMap<CloneCommand> cloneCommands,
       DynamicSet<WebUiPlugin> webUiPlugins,
-      GetArchive.AllowedFormats archiveFormats,
+      AllowedFormats archiveFormats,
       AllProjectsName allProjectsName,
       AllUsersName allUsersName,
       @AnonymousCowardName String anonymousCowardName,
       DynamicItem<AvatarProvider> avatar,
       @EnableSignedPush boolean enableSignedPush,
       QueryDocumentationExecutor docSearcher,
-      NotesMigration migration) {
+      NotesMigration migration,
+      ProjectCache projectCache,
+      AgreementJson agreementJson,
+      GerritOptions gerritOptions,
+      ChangeIndexCollection indexes) {
     this.config = config;
     this.authConfig = authConfig;
     this.realm = realm;
@@ -102,6 +125,10 @@
     this.enableSignedPush = enableSignedPush;
     this.docSearcher = docSearcher;
     this.migration = migration;
+    this.projectCache = projectCache;
+    this.agreementJson = agreementJson;
+    this.gerritOptions = gerritOptions;
+    this.indexes = indexes;
   }
 
   @Override
@@ -133,6 +160,19 @@
     info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
     info.switchAccountUrl = cfg.getSwitchAccountUrl();
     info.isGitBasicAuth = toBoolean(cfg.isGitBasicAuth());
+    info.gitBasicAuthPolicy = cfg.getGitBasicAuthPolicy();
+
+    if (info.useContributorAgreements != null) {
+      Collection<ContributorAgreement> agreements =
+          projectCache.getAllProjects().getConfig().getContributorAgreements();
+      if (!agreements.isEmpty()) {
+        info.contributorAgreements =
+            Lists.newArrayListWithCapacity(agreements.size());
+        for (ContributorAgreement agreement: agreements) {
+          info.contributorAgreements.add(agreementJson.format(agreement));
+        }
+      }
+    }
 
     switch (info.authType) {
       case LDAP:
@@ -169,13 +209,17 @@
     ChangeConfigInfo info = new ChangeConfigInfo();
     info.allowBlame = toBoolean(cfg.getBoolean("change", "allowBlame", true));
     info.allowDrafts = toBoolean(cfg.getBoolean("change", "allowDrafts", true));
+    info.showAssignee = toBoolean(
+        cfg.getBoolean("change", "showAssignee", true)
+            && indexes.getSearchIndex().getSchema()
+                .hasField(ChangeField.ASSIGNEE));
     info.largeChange = cfg.getInt("change", "largeChange", 500);
     info.replyTooltip =
-        Optional.fromNullable(cfg.getString("change", null, "replyTooltip"))
-            .or("Reply and score") + " (Shortcut: a)";
+        Optional.ofNullable(cfg.getString("change", null, "replyTooltip"))
+            .orElse("Reply and score") + " (Shortcut: a)";
     info.replyLabel =
-        Optional.fromNullable(cfg.getString("change", null, "replyLabel"))
-            .or("Reply") + "\u2026";
+        Optional.ofNullable(cfg.getString("change", null, "replyLabel"))
+            .orElse("Reply") + "\u2026";
     info.updateDelay = (int) ConfigUtil.getTimeUnit(
         cfg, "change", null, "updateDelay", 30, TimeUnit.SECONDS);
     info.submitWholeTopic = Submit.wholeTopicEnabled(cfg);
@@ -186,7 +230,7 @@
       DynamicMap<DownloadScheme> downloadSchemes,
       DynamicMap<DownloadCommand> downloadCommands,
       DynamicMap<CloneCommand> cloneCommands,
-      GetArchive.AllowedFormats archiveFormats) {
+      AllowedFormats archiveFormats) {
     DownloadInfo info = new DownloadInfo();
     info.schemes = new HashMap<>();
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
@@ -196,14 +240,8 @@
             getDownloadSchemeInfo(scheme, downloadCommands, cloneCommands));
       }
     }
-    info.archives = Lists.newArrayList(Iterables.transform(
-        archiveFormats.getAllowed(),
-        new Function<ArchiveFormat, String>() {
-          @Override
-          public String apply(ArchiveFormat in) {
-            return in.getShortName();
-          }
-        }));
+    info.archives = archiveFormats.getAllowed().stream()
+        .map(ArchiveFormat::getShortName).collect(toList());
     return info;
   }
 
@@ -251,6 +289,13 @@
     info.docSearch = docSearcher.isAvailable();
     info.editGpgKeys = toBoolean(enableSignedPush
         && cfg.getBoolean("gerrit", null, "editGpgKeys", true));
+    info.webUis = EnumSet.noneOf(UiType.class);
+    if (gerritOptions.enableGwtUi()) {
+      info.webUis.add(UiType.GWT);
+    }
+    if (gerritOptions.enablePolyGerrit()) {
+      info.webUis.add(UiType.POLYGERRIT);
+    }
     return info;
   }
 
@@ -322,85 +367,4 @@
   private static Boolean toBoolean(boolean v) {
     return v ? v : null;
   }
-
-  public static class ServerInfo {
-    public AuthInfo auth;
-    public ChangeConfigInfo change;
-    public DownloadInfo download;
-    public GerritInfo gerrit;
-    public Boolean noteDbEnabled;
-    public PluginConfigInfo plugin;
-    public SshdInfo sshd;
-    public SuggestInfo suggest;
-    public Map<String, String> urlAliases;
-    public UserConfigInfo user;
-    public ReceiveInfo receive;
-  }
-
-  public static class AuthInfo {
-    public AuthType authType;
-    public Boolean useContributorAgreements;
-    public List<Account.FieldName> editableAccountFields;
-    public String loginUrl;
-    public String loginText;
-    public String switchAccountUrl;
-    public String registerUrl;
-    public String registerText;
-    public String editFullNameUrl;
-    public String httpPasswordUrl;
-    public Boolean isGitBasicAuth;
-  }
-
-  public static class ChangeConfigInfo {
-    public Boolean allowBlame;
-    public Boolean allowDrafts;
-    public int largeChange;
-    public String replyLabel;
-    public String replyTooltip;
-    public int updateDelay;
-    public Boolean submitWholeTopic;
-  }
-
-  public static class DownloadInfo {
-    public Map<String, DownloadSchemeInfo> schemes;
-    public List<String> archives;
-  }
-
-  public static class DownloadSchemeInfo {
-    public String url;
-    public Boolean isAuthRequired;
-    public Boolean isAuthSupported;
-    public Map<String, String> commands;
-    public Map<String, String> cloneCommands;
-  }
-
-  public static class GerritInfo {
-    public String allProjects;
-    public String allUsers;
-    public Boolean docSearch;
-    public String docUrl;
-    public Boolean editGpgKeys;
-    public String reportBugUrl;
-    public String reportBugText;
-  }
-
-  public static class PluginConfigInfo {
-    public Boolean hasAvatars;
-    public List<String> jsResourcePaths;
-  }
-
-  public static class SshdInfo {
-  }
-
-  public static class SuggestInfo {
-    public int from;
-  }
-
-  public static class UserConfigInfo {
-    public String anonymousCowardName;
-  }
-
-  public static class ReceiveInfo {
-    public Boolean enableSignedPush;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java
index c7390d7..d8485fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java
@@ -151,6 +151,6 @@
   }
 
   public Set<String> getNames() {
-    return cfg.getNames(PLUGIN, pluginName);
+    return cfg.getNames(PLUGIN, pluginName, true);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
index 33a458e..f7968c8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
@@ -67,7 +67,7 @@
   }
 
   @Override
-  public Object apply(ConfigResource rsrc, Input input)
+  public Response<String> apply(ConfigResource rsrc, Input input)
       throws AuthException, BadRequestException, UnprocessableEntityException {
     if (input == null || input.operation == null) {
       throw new BadRequestException("operation must be specified");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index cc7857c..7d11ff4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.common.base.Function;
-import com.google.common.collect.Lists;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.api.projects.ConfigValue;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
@@ -137,14 +137,9 @@
       T defaultValue, Class<T> permittedValues, boolean inheritable,
       String description) {
     this(displayName, defaultValue.name(), ProjectConfigEntryType.LIST,
-        Lists.transform(
-            Arrays.asList(permittedValues.getEnumConstants()),
-            new Function<Enum<?>, String>() {
-              @Override
-              public String apply(Enum<?> e) {
-                return e.name();
-              }
-            }), inheritable, description);
+        Arrays.stream(permittedValues.getEnumConstants())
+            .map(Enum::name).collect(toList()),
+        inheritable, description);
   }
 
   public ProjectConfigEntry(String displayName, String defaultValue,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
index 8c18514..8f6035a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -26,6 +26,7 @@
     public String number;
     public String subject;
     public AccountAttribute owner;
+    public AccountAttribute assignee;
     public String url;
     public String commitMessage;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 6811056..6beff6c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -16,8 +16,8 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -34,13 +34,12 @@
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -56,6 +55,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 import java.io.IOException;
+import java.util.Optional;
 
 /**
  * Utility functions to manipulate change edits.
@@ -69,7 +69,6 @@
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeIndexer indexer;
-  private final ProjectCache projectCache;
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> user;
   private final ChangeKindCache changeKindCache;
@@ -81,7 +80,6 @@
       PatchSetInserter.Factory patchSetInserterFactory,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeIndexer indexer,
-      ProjectCache projectCache,
       Provider<ReviewDb> db,
       Provider<CurrentUser> user,
       ChangeKindCache changeKindCache,
@@ -91,7 +89,6 @@
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.changeControlFactory = changeControlFactory;
     this.indexer = indexer;
-    this.projectCache = projectCache;
     this.db = db;
     this.user = user;
     this.changeKindCache = changeKindCache;
@@ -147,7 +144,7 @@
       }
       Ref ref = repo.getRefDatabase().firstExactRef(refNames);
       if (ref == null) {
-        return Optional.absent();
+        return Optional.empty();
       }
       try (RevWalk rw = new RevWalk(repo)) {
         RevCommit commit = rw.parseCommit(ref.getObjectId());
@@ -168,24 +165,69 @@
    * @throws UpdateException
    * @throws RestApiException
    */
-  public void publish(ChangeEdit edit) throws NoSuchChangeException,
-      IOException, OrmException, RestApiException, UpdateException {
+  public void publish(final ChangeEdit edit, NotifyHandling notify)
+      throws NoSuchChangeException, IOException, OrmException, RestApiException,
+      UpdateException {
     Change change = edit.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject());
         RevWalk rw = new RevWalk(repo);
-        ObjectInserter inserter = repo.newObjectInserter()) {
+        ObjectInserter oi = repo.newObjectInserter()) {
       PatchSet basePatchSet = edit.getBasePatchSet();
       if (!basePatchSet.getId().equals(change.currentPatchSetId())) {
         throw new ResourceConflictException(
             "only edit for current patch set can be published");
       }
 
-      Change updatedChange =
-          insertPatchSet(edit, change, repo, rw, inserter, basePatchSet,
-              squashEdit(rw, inserter, edit.getEditCommit(), basePatchSet));
-      // TODO(davido): This should happen in the same BatchRefUpdate.
-      deleteRef(repo, edit);
-      indexer.index(db.get(), updatedChange);
+      RevCommit squashed = squashEdit(rw, oi, edit.getEditCommit(), basePatchSet);
+      ChangeControl ctl =
+          changeControlFactory.controlFor(db.get(), change, edit.getUser());
+      PatchSet.Id psId =
+          ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
+      PatchSetInserter inserter = patchSetInserterFactory
+          .create(ctl, psId, squashed)
+          .setNotify(notify);
+
+      StringBuilder message = new StringBuilder("Patch Set ")
+        .append(inserter.getPatchSetId().get())
+        .append(": ");
+
+      // Previously checked that the base patch set is the current patch set.
+      ObjectId prior = ObjectId.fromString(basePatchSet.getRevision().get());
+      ChangeKind kind = changeKindCache.getChangeKind(
+          change.getProject(), repo, prior, squashed);
+      if (kind == ChangeKind.NO_CODE_CHANGE) {
+        message.append("Commit message was updated.");
+      } else {
+        message.append("Published edit on patch set ")
+          .append(basePatchSet.getPatchSetId())
+          .append(".");
+      }
+
+      try (BatchUpdate bu = updateFactory.create(
+          db.get(), change.getProject(), ctl.getUser(),
+          TimeUtil.nowTs())) {
+        bu.setRepository(repo, rw, oi);
+        bu.addOp(change.getId(), inserter
+          .setDraft(change.getStatus() == Status.DRAFT ||
+              basePatchSet.isDraft())
+          .setMessage(message.toString()));
+        bu.addOp(change.getId(), new BatchUpdate.Op() {
+          @Override
+          public void updateRepo(RepoContext ctx) throws Exception {
+            deleteRef(ctx.getRepository(), edit);
+          }
+        });
+        bu.execute();
+      } catch (UpdateException e) {
+        if (e.getCause() instanceof IOException && e.getMessage()
+            .equals(String.format("%s: Failed to delete ref %s: %s",
+                IOException.class.getName(), edit.getRefName(),
+                RefUpdate.Result.LOCK_FAILURE.name()))) {
+          throw new ResourceConflictException("edit ref was updated");
+        }
+      }
+
+      indexer.index(db.get(), inserter.getChange());
     }
   }
 
@@ -230,47 +272,6 @@
     return writeSquashedCommit(rw, inserter, parent, edit);
   }
 
-  private Change insertPatchSet(ChangeEdit edit, Change change,
-      Repository repo, RevWalk rw, ObjectInserter oi, PatchSet basePatchSet,
-      RevCommit squashed) throws NoSuchChangeException, RestApiException,
-      UpdateException, OrmException, IOException {
-    ChangeControl ctl =
-        changeControlFactory.controlFor(db.get(), change, edit.getUser());
-    PatchSet.Id psId =
-        ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
-    PatchSetInserter inserter =
-        patchSetInserterFactory.create(ctl, psId, squashed);
-
-    StringBuilder message = new StringBuilder("Patch Set ")
-      .append(inserter.getPatchSetId().get())
-      .append(": ");
-
-    ProjectState project = projectCache.get(change.getDest().getParentKey());
-    // Previously checked that the base patch set is the current patch set.
-    ObjectId prior = ObjectId.fromString(basePatchSet.getRevision().get());
-    ChangeKind kind = changeKindCache.getChangeKind(project, repo, prior, squashed);
-    if (kind == ChangeKind.NO_CODE_CHANGE) {
-      message.append("Commit message was updated.");
-    } else {
-      message.append("Published edit on patch set ")
-        .append(basePatchSet.getPatchSetId())
-        .append(".");
-    }
-
-    try (BatchUpdate bu = updateFactory.create(
-        db.get(), change.getProject(), ctl.getUser(),
-        TimeUtil.nowTs())) {
-      bu.setRepository(repo, rw, oi);
-      bu.addOp(change.getId(), inserter
-        .setDraft(change.getStatus() == Status.DRAFT ||
-            basePatchSet.isDraft())
-        .setMessage(message.toString()));
-      bu.execute();
-    }
-
-    return inserter.getChange();
-  }
-
   private static void deleteRef(Repository repo, ChangeEdit edit)
       throws IOException {
     String refName = edit.getRefName();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/AssigneeChangedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
new file mode 100644
index 0000000..60a0935
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+
+public class AssigneeChangedEvent extends ChangeEvent {
+  static final String TYPE = "assignee-changed";
+  public Supplier<AccountAttribute> changer;
+  public Supplier<AccountAttribute> oldAssignee;
+
+  public AssigneeChangedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
index 56daccc..fbeb835 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.events;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Comparator.comparing;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
@@ -28,8 +27,8 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.UserIdentity;
@@ -159,6 +158,7 @@
     }
     a.url = getChangeUrl(change);
     a.owner = asAccountAttribute(change.getOwner());
+    a.assignee = asAccountAttribute(change.getAssignee());
     a.status = change.getStatus();
     return a;
   }
@@ -298,22 +298,21 @@
       }
     }
     // Sort by original parent order.
-    Collections.sort(ca.dependsOn, Ordering.natural().onResultOf(
-        new Function<DependencyAttribute, Integer>() {
-          @Override
-          public Integer apply(DependencyAttribute d) {
-            for (int i = 0; i < parentNames.size(); i++) {
-              if (parentNames.get(i).equals(d.revision)) {
-                return i;
+    Collections.sort(
+        ca.dependsOn,
+        comparing(
+            (DependencyAttribute d) -> {
+              for (int i = 0; i < parentNames.size(); i++) {
+                if (parentNames.get(i).equals(d.revision)) {
+                  return i;
+                }
               }
-            }
-            return parentNames.size() + 1;
-          }
-        }));
+              return parentNames.size() + 1;
+            }));
   }
 
-  private void addNeededBy(RevWalk rw, ChangeAttribute ca, Change change,
-      PatchSet currentPs) throws OrmException, IOException {
+  private void addNeededBy(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
+      throws OrmException, IOException {
     if (currentPs.getGroups().isEmpty()) {
       return;
     }
@@ -400,10 +399,10 @@
   }
 
   public void addPatchSetComments(PatchSetAttribute patchSetAttribute,
-      Collection<PatchLineComment> patchLineComments) {
-    for (PatchLineComment comment : patchLineComments) {
-      if (comment.getKey().getParentKey().getParentKey().get()
-          == Integer.parseInt(patchSetAttribute.number)) {
+      Collection<Comment> comments) {
+    for (Comment comment : comments) {
+      if (comment.key.patchSetId ==
+          Integer.parseInt(patchSetAttribute.number)) {
         if (patchSetAttribute.comments == null) {
           patchSetAttribute.comments = new ArrayList<>();
         }
@@ -500,7 +499,7 @@
       List<Patch> list =
           patchListCache.get(change, patchSet).toPatchList(pId);
       for (Patch pe : list) {
-        if (!Patch.COMMIT_MSG.equals(pe.getFileName())) {
+        if (!Patch.isMagic(pe.getFileName())) {
           p.sizeDeletions -= pe.getDeletions();
           p.sizeInsertions += pe.getInsertions();
         }
@@ -639,12 +638,12 @@
     return a;
   }
 
-  public PatchSetCommentAttribute asPatchSetLineAttribute(PatchLineComment c) {
+  public PatchSetCommentAttribute asPatchSetLineAttribute(Comment c) {
     PatchSetCommentAttribute a = new PatchSetCommentAttribute();
-    a.reviewer = asAccountAttribute(c.getAuthor());
-    a.file = c.getKey().getParentKey().get();
-    a.line = c.getLine();
-    a.message = c.getMessage();
+    a.reviewer = asAccountAttribute(c.author.getId());
+    a.file = c.key.filename;
+    a.line = c.lineNbr;
+    a.message = c.message;
     return a;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
index 447e8b2..cd6e2f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
@@ -22,6 +22,7 @@
   private static final Map<String, Class<?>> typesByString = new HashMap<>();
 
   static {
+    register(AssigneeChangedEvent.TYPE, AssigneeChangedEvent.class);
     register(ChangeAbandonedEvent.TYPE, ChangeAbandonedEvent.class);
     register(ChangeMergedEvent.TYPE, ChangeMergedEvent.class);
     register(ChangeRestoredEvent.TYPE, ChangeRestoredEvent.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 5294391..c867d26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
@@ -73,6 +74,7 @@
 
 @Singleton
 public class StreamEventsApiListener implements
+    AssigneeChangedListener,
     ChangeAbandonedListener,
     ChangeMergedListener,
     ChangeRestoredListener,
@@ -91,6 +93,8 @@
   public static class Module extends AbstractModule {
     @Override
     protected void configure() {
+      DynamicSet.bind(binder(), AssigneeChangedListener.class)
+        .to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeAbandonedListener.class)
         .to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeMergedListener.class)
@@ -177,8 +181,8 @@
         new Supplier<AccountAttribute>() {
           @Override
           public AccountAttribute get() {
-            return eventFactory.asAccountAttribute(
-                new Account.Id(account._accountId));
+            return account != null ? eventFactory.asAccountAttribute(
+                new Account.Id(account._accountId)) : null;
           }
         });
   }
@@ -266,6 +270,22 @@
   }
 
   @Override
+  public void onAssigneeChanged(AssigneeChangedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      AssigneeChangedEvent event = new AssigneeChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getWho());
+      event.oldAssignee = accountAttributeSupplier(ev.getOldAssignee());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
   public void onTopicEdited(TopicEditedListener.Event ev) {
     try {
       Change change = getChange(ev.getChange());
@@ -321,7 +341,7 @@
   }
 
   @Override
-  public void onReviewerAdded(ReviewerAddedListener.Event ev) {
+  public void onReviewersAdded(ReviewerAddedListener.Event ev) {
     try {
       ChangeNotes notes = getNotes(ev.getChange());
       Change change = notes.getChange();
@@ -330,9 +350,10 @@
       event.change = changeAttributeSupplier(change);
       event.patchSet = patchSetAttributeSupplier(change,
           psUtil.current(db.get(), notes));
-      event.reviewer = accountAttributeSupplier(ev.getReviewer());
-
-      dispatcher.get().postEvent(change, event);
+      for (AccountInfo reviewer : ev.getReviewers()) {
+        event.reviewer = accountAttributeSupplier(reviewer);
+        dispatcher.get().postEvent(change, event);
+      }
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
new file mode 100644
index 0000000..53d837f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.AssigneeChangedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.Timestamp;
+
+public class AssigneeChanged {
+  private static final Logger log =
+      LoggerFactory.getLogger(AssigneeChanged.class);
+
+  private final DynamicSet<AssigneeChangedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  AssigneeChanged(DynamicSet<AssigneeChangedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, Account account, Account oldAssignee,
+      Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(
+          util.changeInfo(change),
+          util.accountInfo(account),
+          util.accountInfo(oldAssignee),
+          when);
+      for (AssigneeChangedListener l : listeners) {
+        try {
+          l.onAssigneeChanged(event);
+        } catch (Exception e) {
+          util.logEventListenerError(event, l, e);
+        }
+      }
+    } catch (OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent
+      implements AssigneeChangedListener.Event {
+    private final AccountInfo oldAssignee;
+
+    Event(ChangeInfo change, AccountInfo editor, AccountInfo oldAssignee,
+        Timestamp when) {
+      super(change, editor, when, NotifyHandling.ALL);
+      this.oldAssignee = oldAssignee;
+    }
+
+    @Override
+    public AccountInfo getOldAssignee() {
+      return oldAssignee;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index e303d8b..5a7aec2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -74,22 +74,15 @@
 
   private static class Event extends AbstractRevisionEvent
       implements ChangeAbandonedListener.Event {
-    private final AccountInfo abandoner;
     private final String reason;
 
     Event(ChangeInfo change, RevisionInfo revision, AccountInfo abandoner,
         String reason, Timestamp when, NotifyHandling notifyHandling) {
       super(change, revision, abandoner, when, notifyHandling);
-      this.abandoner = abandoner;
       this.reason = reason;
     }
 
     @Override
-    public AccountInfo getAbandoner() {
-      return abandoner;
-    }
-
-    @Override
     public String getReason() {
       return reason;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index 00d276b..8b4a6a0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -74,22 +74,15 @@
 
   private static class Event extends AbstractRevisionEvent
       implements ChangeMergedListener.Event {
-    private final AccountInfo merger;
     private final String newRevisionId;
 
     Event(ChangeInfo change, RevisionInfo revision, AccountInfo merger,
         String newRevisionId, Timestamp when) {
       super(change, revision, merger, when, NotifyHandling.ALL);
-      this.merger = merger;
       this.newRevisionId = newRevisionId;
     }
 
     @Override
-    public AccountInfo getMerger() {
-      return merger;
-    }
-
-    @Override
     public String getNewRevisionId() {
       return newRevisionId;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 5dda4d1..1d2682a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -75,22 +75,15 @@
   private static class Event extends AbstractRevisionEvent
       implements ChangeRestoredListener.Event {
 
-    private AccountInfo restorer;
     private String reason;
 
     Event(ChangeInfo change, RevisionInfo revision, AccountInfo restorer,
         String reason, Timestamp when) {
       super(change, revision, restorer, when, NotifyHandling.ALL);
-      this.restorer = restorer;
       this.reason = reason;
     }
 
     @Override
-    public AccountInfo getRestorer() {
-      return restorer;
-    }
-
-    @Override
     public String getReason() {
       return reason;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 0c75e2e..f1bb50a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -80,7 +80,6 @@
   private static class Event extends AbstractRevisionEvent
       implements CommentAddedListener.Event {
 
-    private final AccountInfo author;
     private final String comment;
     private final Map<String, ApprovalInfo> approvals;
     private final Map<String, ApprovalInfo> oldApprovals;
@@ -89,18 +88,12 @@
         String comment, Map<String, ApprovalInfo> approvals,
         Map<String, ApprovalInfo> oldApprovals, Timestamp when) {
       super(change, revision, author, when, NotifyHandling.ALL);
-      this.author = author;
       this.comment = comment;
       this.approvals = approvals;
       this.oldApprovals = oldApprovals;
     }
 
     @Override
-    public AccountInfo getAuthor() {
-      return author;
-    }
-
-    @Override
     public String getComment() {
       return comment;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
index 9e3e5a2..4f6d298 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
@@ -71,17 +71,10 @@
 
   private static class Event extends AbstractRevisionEvent
       implements DraftPublishedListener.Event {
-    private final AccountInfo publisher;
 
     Event(ChangeInfo change, RevisionInfo revision, AccountInfo publisher,
         Timestamp when) {
       super(change, revision, publisher, when, NotifyHandling.ALL);
-      this.publisher = publisher;
-    }
-
-    @Override
-    public AccountInfo getPublisher() {
-      return publisher;
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
index e519410..5e4eb6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.account.AccountJson;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
@@ -83,18 +84,14 @@
     if (a == null || a.getId() == null) {
       return null;
     }
-    AccountInfo ai = new AccountInfo(a.getId().get());
-    ai.email = a.getPreferredEmail();
-    ai.name = a.getFullName();
-    ai.username = a.getUserName();
-    return ai;
+    return AccountJson.toAccountInfo(a);
   }
 
   public Map<String, ApprovalInfo> approvals(Account a,
       Map<String, Short> approvals, Timestamp ts) {
     Map<String, ApprovalInfo> result = new HashMap<>();
     for (Map.Entry<String, Short> e : approvals.entrySet()) {
-      Integer value = e.getValue() != null ? new Integer(e.getValue()) : null;
+      Integer value = e.getValue() != null ? Integer.valueOf(e.getValue()) : null;
       result.put(e.getKey(),
           ChangeJson.getApprovalInfo(a.getId(), value, null, ts));
     }
@@ -104,12 +101,31 @@
   public void logEventListenerError(Object event, Object listener,
       Exception error) {
     if (log.isDebugEnabled()) {
-      log.debug(String.format(
-          "Error in event listener %s for event %s",
-          listener.getClass().getName(), event.getClass().getName()), error);
+      log.debug(
+          String.format(
+              "Error in event listener %s for event %s",
+              listener.getClass().getName(),
+              event.getClass().getName()),
+          error);
     } else {
-      log.warn("Error in listener {} for event {}: {}",
-          listener.getClass().getName(), event.getClass().getName(),
+      log.warn(
+          "Error in listener {} for event {}: {}",
+          listener.getClass().getName(),
+          event.getClass().getName(),
+          error.getMessage());
+    }
+  }
+
+  public static void logEventListenerError(Object listener, Exception error) {
+    if (log.isDebugEnabled()) {
+      log.debug(
+          String.format(
+              "Error in event listener %s", listener.getClass().getName()),
+          error);
+    } else {
+      log.warn(
+          "Error in listener {}: {}",
+          listener.getClass().getName(),
           error.getMessage());
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index 27770fd..233a89e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -73,7 +73,6 @@
   private static class Event extends AbstractChangeEvent
       implements HashtagsEditedListener.Event {
 
-    private AccountInfo editor;
     private Collection<String> updatedHashtags;
     private Collection<String> addedHashtags;
     private Collection<String> removedHashtags;
@@ -81,18 +80,12 @@
     Event(ChangeInfo change, AccountInfo editor, Collection<String> updated,
         Collection<String> added, Collection<String> removed, Timestamp when) {
       super(change, editor, when, NotifyHandling.ALL);
-      this.editor = editor;
       this.updatedHashtags = updated;
       this.addedHashtags = added;
       this.removedHashtags = removed;
     }
 
     @Override
-    public AccountInfo getEditor() {
-      return editor;
-    }
-
-    @Override
     public Collection<String> getHashtags() {
       return updatedHashtags;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index e9c44a5..8860a42 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.extensions.events;
 
+import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -33,6 +34,7 @@
 
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.List;
 
 public class ReviewerAdded {
   private static final Logger log =
@@ -48,21 +50,22 @@
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet patchSet, Account account,
+  public void fire(Change change, PatchSet patchSet, List<Account> reviewers,
       Account adder, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+    if (!listeners.iterator().hasNext() || reviewers.isEmpty()) {
       return;
     }
+
     try {
       Event event = new Event(
           util.changeInfo(change),
           util.revisionInfo(change.getProject(), patchSet),
-          util.accountInfo(account),
+          Lists.transform(reviewers, util::accountInfo),
           util.accountInfo(adder),
           when);
       for (ReviewerAddedListener l : listeners) {
         try {
-          l.onReviewerAdded(event);
+          l.onReviewersAdded(event);
         } catch (Exception e) {
           util.logEventListenerError(this, l, e);
         }
@@ -75,17 +78,17 @@
 
   private static class Event extends AbstractRevisionEvent
       implements ReviewerAddedListener.Event {
-    private final AccountInfo reviewer;
+    private final List<AccountInfo> reviewers;
 
-    Event(ChangeInfo change, RevisionInfo revision, AccountInfo reviewer,
+    Event(ChangeInfo change, RevisionInfo revision, List<AccountInfo> reviewers,
         AccountInfo adder, Timestamp when) {
       super(change, revision, adder, when, NotifyHandling.ALL);
-      this.reviewer = reviewer;
+      this.reviewers = reviewers;
     }
 
     @Override
-    public AccountInfo getReviewer() {
-      return reviewer;
+    public List<AccountInfo> getReviewers() {
+      return reviewers;
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 42aa9a3..4bc4764 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -51,9 +51,8 @@
   }
 
   public void fire(Change change, PatchSet patchSet, Account reviewer,
-      Account remover, String message,
-      Map<String, Short> newApprovals,
-      Map<String, Short> oldApprovals, Timestamp when) {
+      Account remover, String message, Map<String, Short> newApprovals,
+      Map<String, Short> oldApprovals, NotifyHandling notify, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
@@ -66,6 +65,7 @@
           message,
           util.approvals(reviewer, newApprovals, when),
           util.approvals(reviewer, oldApprovals, when),
+          notify,
           when);
       for (ReviewerDeletedListener listener : listeners) {
         try {
@@ -91,8 +91,9 @@
     Event(ChangeInfo change, RevisionInfo revision, AccountInfo reviewer,
         AccountInfo remover, String comment,
         Map<String, ApprovalInfo> newApprovals,
-        Map<String, ApprovalInfo> oldApprovals, Timestamp when) {
-      super(change, revision, remover, when, NotifyHandling.ALL);
+        Map<String, ApprovalInfo> oldApprovals, NotifyHandling notify,
+        Timestamp when) {
+      super(change, revision, remover, when, notify);
       this.reviewer = reviewer;
       this.comment = comment;
       this.newApprovals = newApprovals;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 27f3be5..7f03c63 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -74,17 +74,10 @@
 
   private static class Event extends AbstractRevisionEvent
       implements RevisionCreatedListener.Event {
-    private final AccountInfo uploader;
 
     Event(ChangeInfo change, RevisionInfo revision, AccountInfo uploader,
         Timestamp when, NotifyHandling notify) {
       super(change, revision, uploader, when, notify);
-      this.uploader = uploader;
-    }
-
-    @Override
-    public AccountInfo getUploader() {
-      return uploader;
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index bf1b2ba..2e583a8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -68,22 +68,15 @@
 
   private static class Event extends AbstractChangeEvent
       implements TopicEditedListener.Event {
-    private final AccountInfo editor;
     private final String oldTopic;
 
     Event(ChangeInfo change, AccountInfo editor, String oldTopic,
         Timestamp when) {
       super(change, editor, when, NotifyHandling.ALL);
-      this.editor = editor;
       this.oldTopic = oldTopic;
     }
 
     @Override
-    public AccountInfo getEditor() {
-      return editor;
-    }
-
-    @Override
     public String getOldTopic() {
       return oldTopic;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
index 601bcc6..3dbd1e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -14,11 +14,8 @@
 
 package com.google.gerrit.server.extensions.webui;
 
-import com.google.common.base.Function;
 import com.google.common.base.Predicate;
-import com.google.common.base.Predicates;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.Nullable;
+import com.google.common.collect.FluentIterable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestCollection;
@@ -33,16 +30,13 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.Objects;
+
 public class UiActions {
   private static final Logger log = LoggerFactory.getLogger(UiActions.class);
 
   public static Predicate<UiAction.Description> enabled() {
-    return new Predicate<UiAction.Description>() {
-      @Override
-      public boolean apply(UiAction.Description input) {
-        return input.isEnabled();
-      }
-    };
+    return UiAction.Description::isEnabled;
   }
 
   public static <R extends RestResource> Iterable<UiAction.Description> from(
@@ -56,58 +50,52 @@
       DynamicMap<RestView<R>> views,
       final R resource,
       final Provider<CurrentUser> userProvider) {
-    return Iterables.filter(
-      Iterables.transform(
-        views,
-        new Function<DynamicMap.Entry<RestView<R>>, UiAction.Description> () {
-          @Override
-          @Nullable
-          public UiAction.Description apply(DynamicMap.Entry<RestView<R>> e) {
-            int d = e.getExportName().indexOf('.');
-            if (d < 0) {
-              return null;
-            }
+    return FluentIterable.from(views)
+        .transform((DynamicMap.Entry<RestView<R>> e) -> {
+              int d = e.getExportName().indexOf('.');
+              if (d < 0) {
+                return null;
+              }
 
-            RestView<R> view;
-            try {
-              view = e.getProvider().get();
-            } catch (RuntimeException err) {
-              log.error(String.format(
-                  "error creating view %s.%s",
-                  e.getPluginName(), e.getExportName()), err);
-              return null;
-            }
+              RestView<R> view;
+              try {
+                view = e.getProvider().get();
+              } catch (RuntimeException err) {
+                log.error(String.format(
+                    "error creating view %s.%s",
+                    e.getPluginName(), e.getExportName()), err);
+                return null;
+              }
 
-            if (!(view instanceof UiAction)) {
-              return null;
-            }
+              if (!(view instanceof UiAction)) {
+                return null;
+              }
 
-            try {
-              CapabilityUtils.checkRequiresCapability(userProvider,
-                  e.getPluginName(), view.getClass());
-            } catch (AuthException exc) {
-              return null;
-            }
+              try {
+                CapabilityUtils.checkRequiresCapability(userProvider,
+                    e.getPluginName(), view.getClass());
+              } catch (AuthException exc) {
+                return null;
+              }
 
-            UiAction.Description dsc =
-                ((UiAction<R>) view).getDescription(resource);
-            if (dsc == null || !dsc.isVisible()) {
-              return null;
-            }
+              UiAction.Description dsc =
+                  ((UiAction<R>) view).getDescription(resource);
+              if (dsc == null || !dsc.isVisible()) {
+                return null;
+              }
 
-            String name = e.getExportName().substring(d + 1);
-            PrivateInternals_UiActionDescription.setMethod(
-                dsc,
-                e.getExportName().substring(0, d));
-            PrivateInternals_UiActionDescription.setId(
-                dsc,
-                "gerrit".equals(e.getPluginName())
-                  ? name
-                  : e.getPluginName() + '~' + name);
-            return dsc;
-          }
-        }),
-      Predicates.notNull());
+              String name = e.getExportName().substring(d + 1);
+              PrivateInternals_UiActionDescription.setMethod(
+                  dsc,
+                  e.getExportName().substring(0, d));
+              PrivateInternals_UiActionDescription.setId(
+                  dsc,
+                  "gerrit".equals(e.getPluginName())
+                    ? name
+                    : e.getPluginName() + '~' + name);
+              return dsc;
+            })
+        .filter(Objects::nonNull);
   }
 
   private UiActions() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
index 370d9a6..dc229b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
@@ -17,10 +17,10 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.Comparator.comparing;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
 
+import com.google.common.base.Stopwatch;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
@@ -34,19 +34,21 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -62,11 +64,12 @@
 import com.google.gwtorm.server.OrmConcurrencyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -226,7 +229,7 @@
       this.dbWrapper = dbWrapper;
       this.threadLocalRepo = repo;
       this.threadLocalRevWalk = rw;
-      updates = new TreeMap<>(ReviewDbUtil.intKeyOrdering());
+      updates = new TreeMap<>(comparing(PatchSet.Id::get));
     }
 
     @Override
@@ -346,6 +349,22 @@
     }
   }
 
+  @Singleton
+  private static class Metrics {
+    final Timer1<Boolean> executeChangeOpsLatency;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      executeChangeOpsLatency = metricMaker.newTimer(
+          "batch_update/execute_change_ops",
+          new Description(
+                  "BatchUpdate change update latency, excluding reindexing")
+              .setCumulative()
+              .setUnit(Units.MILLISECONDS),
+          Field.ofBoolean("success"));
+    }
+  }
+
   private static Order getOrder(Collection<BatchUpdate> updates) {
     Order o = null;
     for (BatchUpdate u : updates) {
@@ -379,7 +398,8 @@
   }
 
   static void execute(Collection<BatchUpdate> updates, Listener listener,
-      @Nullable RequestId requestId) throws UpdateException, RestApiException {
+      @Nullable RequestId requestId, boolean dryrun)
+          throws UpdateException, RestApiException {
     if (updates.isEmpty()) {
       return;
     }
@@ -401,17 +421,19 @@
           }
           listener.afterUpdateRepos();
           for (BatchUpdate u : updates) {
-            u.executeRefUpdates();
+            u.executeRefUpdates(dryrun);
           }
           listener.afterRefUpdates();
           for (BatchUpdate u : updates) {
-            u.executeChangeOps(updateChangesInParallel);
+            u.reindexChanges(
+              u.executeChangeOps(updateChangesInParallel, dryrun));
           }
           listener.afterUpdateChanges();
           break;
         case DB_BEFORE_REPO:
           for (BatchUpdate u : updates) {
-            u.executeChangeOps(updateChangesInParallel);
+            u.reindexChanges(
+                u.executeChangeOps(updateChangesInParallel, dryrun));
           }
           listener.afterUpdateChanges();
           for (BatchUpdate u : updates) {
@@ -419,7 +441,7 @@
           }
           listener.afterUpdateRepos();
           for (BatchUpdate u : updates) {
-            u.executeRefUpdates();
+            u.executeRefUpdates(dryrun);
           }
           listener.afterRefUpdates();
           break;
@@ -447,9 +469,10 @@
                   : null);
         }
       }
-
-      for (BatchUpdate u : updates) {
-        u.executePostOps();
+      if (!dryrun) {
+        for (BatchUpdate u : updates) {
+          u.executePostOps();
+        }
       }
     } catch (UpdateException | RestApiException e) {
       // Propagate REST API exceptions thrown by operations; they commonly throw
@@ -466,7 +489,7 @@
       throw new ResourceNotFoundException(e.getMessage(), e);
 
     } catch (Exception e) {
-      Throwables.propagateIfPossible(e);
+      Throwables.throwIfUnchecked(e);
       throw new UpdateException(e);
     }
   }
@@ -479,12 +502,12 @@
   private final GitReferenceUpdated gitRefUpdated;
   private final GitRepositoryManager repoManager;
   private final ListeningExecutorService changeUpdateExector;
+  private final Metrics metrics;
   private final NoteDbUpdateManager.Factory updateManagerFactory;
   private final NotesMigration notesMigration;
   private final ReviewDb db;
   private final SchemaFactory<ReviewDb> schemaFactory;
 
-  private final long logThresholdNanos;
   private final Project.NameKey project;
   private final CurrentUser user;
   private final Timestamp when;
@@ -509,7 +532,6 @@
 
   @AssistedInject
   BatchUpdate(
-      @GerritServerConfig Config cfg,
       AllUsersName allUsers,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeIndexer indexer,
@@ -519,6 +541,7 @@
       @GerritPersonIdent PersonIdent serverIdent,
       GitReferenceUpdated gitRefUpdated,
       GitRepositoryManager repoManager,
+      Metrics metrics,
       NoteDbUpdateManager.Factory updateManagerFactory,
       NotesMigration notesMigration,
       SchemaFactory<ReviewDb> schemaFactory,
@@ -533,15 +556,11 @@
     this.changeUpdateFactory = changeUpdateFactory;
     this.gitRefUpdated = gitRefUpdated;
     this.indexer = indexer;
+    this.metrics = metrics;
     this.notesMigration = notesMigration;
     this.repoManager = repoManager;
     this.schemaFactory = schemaFactory;
     this.updateManagerFactory = updateManagerFactory;
-
-    this.logThresholdNanos = MILLISECONDS.toNanos(
-        ConfigUtil.getTimeUnit(
-            cfg, "change", null, "updateDebugLogThreshold",
-            SECONDS.toMillis(2), MILLISECONDS));
     this.db = db;
     this.project = project;
     this.user = user;
@@ -640,13 +659,17 @@
     return this;
   }
 
+  public Collection<ReceiveCommand> getRefUpdates() {
+    return commands.getCommands().values();
+  }
+
   public void execute() throws UpdateException, RestApiException {
     execute(Listener.NONE);
   }
 
   public void execute(Listener listener)
       throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId);
+    execute(ImmutableList.of(this), listener, requestId, false);
   }
 
   private void executeUpdateRepo() throws UpdateException, RestApiException {
@@ -657,11 +680,9 @@
         op.updateRepo(ctx);
       }
 
-      if (!repoOnlyOps.isEmpty()) {
-        logDebug("Executing updateRepo on {} RepoOnlyOps", ops.size());
-        for (RepoOnlyOp op : repoOnlyOps) {
-          op.updateRepo(ctx);
-        }
+      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
       }
 
       if (inserter != null) {
@@ -671,12 +692,13 @@
         logDebug("No objects to flush");
       }
     } catch (Exception e) {
-      Throwables.propagateIfPossible(e, RestApiException.class);
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
       throw new UpdateException(e);
     }
   }
 
-  private void executeRefUpdates() throws IOException, UpdateException {
+  private void executeRefUpdates(boolean dryrun)
+      throws IOException, RestApiException {
     if (commands == null || commands.isEmpty()) {
       logDebug("No ref updates to execute");
       return;
@@ -687,6 +709,10 @@
     commands.addTo(batchRefUpdate);
     logDebug("Executing batch of {} ref updates",
         batchRefUpdate.getCommands().size());
+    if (dryrun) {
+      return;
+    }
+
     batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
     boolean ok = true;
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
@@ -696,63 +722,75 @@
       }
     }
     if (!ok) {
-      throw new UpdateException("BatchRefUpdate failed: " + batchRefUpdate);
+      throw new RestApiException("BatchRefUpdate failed: " + batchRefUpdate);
     }
   }
 
-  private void executeChangeOps(boolean parallel)
-      throws UpdateException, RestApiException {
-    logDebug("Executing change ops (parallel? {})", parallel);
-    ListeningExecutorService executor = parallel
-        ? changeUpdateExector
-        : MoreExecutors.newDirectExecutorService();
-
-    List<ChangeTask> tasks = new ArrayList<>(ops.keySet().size());
+  private List<ChangeTask> executeChangeOps(boolean parallel,
+      boolean dryrun) throws UpdateException,
+      RestApiException {
+    List<ChangeTask> tasks;
+    boolean success = false;
+    Stopwatch sw = Stopwatch.createStarted();
     try {
-      if (notesMigration.commitChangeWrites() && repo != null) {
-        // A NoteDb change may have been rebuilt since the repo was originally
-        // opened, so make sure we see that.
-        logDebug("Preemptively scanning for repo changes");
-        repo.scanForRepoChanges();
-      }
-      if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
-        // Fail fast before attempting any writes if changes are read-only, as
-        // this is a programmer error.
-        logDebug("Failing early due to read-only Changes table");
-        throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
-      }
-      List<ListenableFuture<?>> futures = new ArrayList<>(ops.keySet().size());
-      for (Map.Entry<Change.Id, Collection<Op>> e : ops.asMap().entrySet()) {
-        ChangeTask task =
-            new ChangeTask(e.getKey(), e.getValue(), Thread.currentThread());
-        tasks.add(task);
-        if (!parallel) {
-          logDebug("Direct execution of task for ops: {}", ops);
+      logDebug("Executing change ops (parallel? {})", parallel);
+      ListeningExecutorService executor = parallel
+          ? changeUpdateExector
+          : MoreExecutors.newDirectExecutorService();
+
+      tasks = new ArrayList<>(ops.keySet().size());
+      try {
+        if (notesMigration.commitChangeWrites() && repo != null) {
+          // A NoteDb change may have been rebuilt since the repo was originally
+          // opened, so make sure we see that.
+          logDebug("Preemptively scanning for repo changes");
+          repo.scanForRepoChanges();
         }
-        futures.add(executor.submit(task));
-      }
-      if (parallel) {
-        logDebug("Waiting on futures for {} ops spanning {} changes",
-            ops.size(), ops.keySet().size());
-      }
-      // TODO(dborowitz): Timing is wrong for non-parallel updates.
-      long startNanos = System.nanoTime();
-      Futures.allAsList(futures).get();
-      maybeLogSlowUpdate(startNanos, "change");
+        if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
+          // Fail fast before attempting any writes if changes are read-only, as
+          // this is a programmer error.
+          logDebug("Failing early due to read-only Changes table");
+          throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
+        }
+        List<ListenableFuture<?>> futures =
+            new ArrayList<>(ops.keySet().size());
+        for (Map.Entry<Change.Id, Collection<Op>> e : ops.asMap().entrySet()) {
+          ChangeTask task =
+              new ChangeTask(e.getKey(), e.getValue(), Thread.currentThread(),
+                  dryrun);
+          tasks.add(task);
+          if (!parallel) {
+            logDebug("Direct execution of task for ops: {}", ops);
+          }
+          futures.add(executor.submit(task));
+        }
+        if (parallel) {
+          logDebug("Waiting on futures for {} ops spanning {} changes",
+              ops.size(), ops.keySet().size());
+        }
+        Futures.allAsList(futures).get();
 
-      if (notesMigration.commitChangeWrites()) {
-        startNanos = System.nanoTime();
-        executeNoteDbUpdates(tasks);
-        maybeLogSlowUpdate(startNanos, "NoteDb");
+        if (notesMigration.commitChangeWrites()) {
+          if (!dryrun) {
+            executeNoteDbUpdates(tasks);
+          }
+        }
+        success = true;
+      } catch (ExecutionException | InterruptedException e) {
+        Throwables.throwIfInstanceOf(e.getCause(), UpdateException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), RestApiException.class);
+        throw new UpdateException(e);
+      } catch (OrmException | IOException e) {
+        throw new UpdateException(e);
       }
-    } catch (ExecutionException | InterruptedException e) {
-      Throwables.propagateIfInstanceOf(e.getCause(), UpdateException.class);
-      Throwables.propagateIfInstanceOf(e.getCause(), RestApiException.class);
-      throw new UpdateException(e);
-    } catch (OrmException | IOException e) {
-      throw new UpdateException(e);
+    } finally {
+      metrics.executeChangeOpsLatency.record(
+          success, sw.elapsed(NANOSECONDS), NANOSECONDS);
     }
+    return tasks;
+  }
 
+  private void reindexChanges(List<ChangeTask> tasks) {
     // Reindex changes.
     for (ChangeTask task : tasks) {
       if (task.deleted) {
@@ -763,26 +801,6 @@
     }
   }
 
-  private static class SlowUpdateException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    private SlowUpdateException(String fmt, Object... args) {
-      super(String.format(fmt, args));
-    }
-  }
-
-  private void maybeLogSlowUpdate(long startNanos, String desc) {
-    long elapsedNanos = System.nanoTime() - startNanos;
-    if (!log.isDebugEnabled() || elapsedNanos <= logThresholdNanos) {
-      return;
-    }
-    // Always log even without RequestId.
-    log.debug("Slow " + desc + " update",
-        new SlowUpdateException(
-            "Slow %s update (%d ms) to %s for %s",
-            desc, NANOSECONDS.toMillis(elapsedNanos), project, ops.keySet()));
-  }
-
   private void executeNoteDbUpdates(List<ChangeTask> tasks) {
     // Aggregate together all NoteDb ref updates from the ops we executed,
     // possibly in parallel. Each task had its own NoteDbUpdateManager instance
@@ -874,6 +892,7 @@
     final Change.Id id;
     private final Collection<Op> changeOps;
     private final Thread mainThread;
+    private final boolean dryrun;
 
     NoteDbUpdateManager.StagedResult noteDbResult;
     boolean dirty;
@@ -881,10 +900,11 @@
     private String taskId;
 
     private ChangeTask(Change.Id id, Collection<Op> changeOps,
-        Thread mainThread) {
+        Thread mainThread, boolean dryrun) {
       this.id = id;
       this.changeOps = changeOps;
       this.mainThread = mainThread;
+      this.dryrun = dryrun;
     }
 
     @Override
@@ -953,7 +973,9 @@
             logDebug("Updating change");
             db.changes().update(cs);
           }
-          db.commit();
+          if (!dryrun) {
+            db.commit();
+          }
         } finally {
           db.rollback();
         }
@@ -991,7 +1013,7 @@
         RevWalk rw, Change.Id id) throws Exception {
       Change c = newChanges.get(id);
       if (c == null) {
-        c = ReviewDbUtil.unwrapDb(db).changes().get(id);
+        c = ChangeNotes.readOneReviewDbChange(db, id);
       }
       // Pass in preloaded change to controlFor, to avoid:
       //  - reading from a db that does not belong to this update
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java
index cfbaa41..a3b30d1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java
@@ -17,8 +17,6 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 
-import com.google.common.base.Optional;
-
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -28,6 +26,7 @@
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.Optional;
 
 /**
  * Collection of {@link ReceiveCommand}s that supports multiple updates per ref.
@@ -96,7 +95,7 @@
     if (cmd != null) {
       return !cmd.getNewId().equals(ObjectId.zeroId())
           ? Optional.of(cmd.getNewId())
-          : Optional.<ObjectId>absent();
+          : Optional.empty();
     }
     return refCache.get(refName);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
index 857cbea..16e4bd9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
@@ -15,17 +15,14 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.SetMultimap;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 
@@ -85,16 +82,6 @@
     return changeData;
   }
 
-  public SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject()
-      throws OrmException {
-    SetMultimap<Project.NameKey, Branch.NameKey> ret =
-        HashMultimap.create();
-    for (ChangeData cd : changeData.values()) {
-      ret.put(cd.change().getProject(), cd.change().getDest());
-    }
-    return ret;
-  }
-
   public Multimap<Branch.NameKey, ChangeData> changesByBranch()
       throws OrmException {
     ListMultimap<Branch.NameKey, ChangeData> ret =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
index f07b922..27767c0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -46,14 +45,11 @@
    * AnyObjectId} and only orders on SHA-1.
    */
   public static final Ordering<CodeReviewCommit> ORDER = Ordering.natural()
-      .onResultOf(new Function<CodeReviewCommit, Integer>() {
-        @Override
-        public Integer apply(CodeReviewCommit in) {
-          return in.getPatchsetId() != null
-              ? in.getPatchsetId().getParentKey().get()
-              : null;
-        }
-      }).nullsFirst();
+      .onResultOf((CodeReviewCommit c) ->
+          c.getPatchsetId() != null
+              ? c.getPatchsetId().getParentKey().get()
+              : null)
+      .nullsFirst();
 
   public static CodeReviewRevWalk newRevWalk(Repository repo) {
     return new CodeReviewRevWalk(repo);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java
index 66e0704..6817bac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.MergedSender;
+import com.google.gerrit.server.mail.send.MergedSender;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gwtorm.server.OrmException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
index 0e954f3..26c59c2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
@@ -67,7 +67,7 @@
     logDebug("Loading .gitmodules of {} for project {}", branch, project);
     OpenRepo or;
     try {
-      or = orm.openRepo(project, false);
+      or = orm.openRepo(project);
     } catch (NoSuchProjectException e) {
       throw new IOException(e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
index d832260..795a838 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
@@ -18,7 +18,6 @@
 import static org.eclipse.jgit.revwalk.RevFlag.UNINTERESTING;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableList;
@@ -158,13 +157,7 @@
   private static Multimap<ObjectId, PatchSet.Id> transformRefs(
       Multimap<ObjectId, Ref> refs) {
     return Multimaps.transformValues(
-        refs,
-        new Function<Ref, PatchSet.Id>() {
-          @Override
-          public PatchSet.Id apply(Ref in) {
-            return PatchSet.Id.fromRef(in.getName());
-          }
-        });
+        refs, r -> PatchSet.Id.fromRef(r.getName()));
   }
 
   @VisibleForTesting
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
index bd76ad4..880fc0b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
@@ -16,6 +16,10 @@
 
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.ArrayList;
@@ -26,6 +30,8 @@
 import java.util.Set;
 
 public class GroupList extends TabFile {
+  private static final Logger log = LoggerFactory.getLogger(GroupList.class);
+
   public static final String FILE_NAME = "groups";
 
   private final Map<AccountGroup.UUID, GroupReference> byUUID;
@@ -34,12 +40,16 @@
     this.byUUID = byUUID;
   }
 
-  public static GroupList parse(String text, ValidationError.Sink errors)
-      throws IOException {
+  public static GroupList parse(Project.NameKey project, String text,
+      ValidationError.Sink errors) throws IOException {
     List<Row> rows = parse(text, FILE_NAME, TRIM, TRIM, errors);
     Map<AccountGroup.UUID, GroupReference> groupsByUUID =
         new HashMap<>(rows.size());
     for (Row row : rows) {
+      if (row.left == null) {
+        log.warn("null field in group list for {}:\n{}", project, text);
+        continue;
+      }
       AccountGroup.UUID uuid = new AccountGroup.UUID(row.left);
       String name = row.right;
       GroupReference ref = new GroupReference(uuid, name);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
index 9d62721..0062694 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
@@ -17,21 +17,17 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
 import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
@@ -49,7 +45,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -62,7 +57,7 @@
 import com.google.gerrit.server.git.validators.MergeValidators;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.RequestId;
@@ -105,6 +100,9 @@
 public class MergeOp implements AutoCloseable {
   private static final Logger log = LoggerFactory.getLogger(MergeOp.class);
 
+  private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS =
+      SubmitRuleOptions.defaults().build();
+
   public static class CommitStatus {
     private final ImmutableMap<Change.Id, ChangeData> changes;
     private final ImmutableSetMultimap<Branch.NameKey, Change.Id> byBranch;
@@ -122,13 +120,10 @@
       }
       byBranch = bb.build();
       commits = new HashMap<>();
-      problems = MultimapBuilder.treeKeys(
-          Ordering.natural().onResultOf(new Function<Change.Id, Integer>() {
-            @Override
-            public Integer apply(Change.Id in) {
-              return in.get();
-            }
-          })).arrayListValues(1).build();
+      problems = MultimapBuilder
+          .treeKeys(comparing(Change.Id::get))
+          .arrayListValues(1)
+          .build();
     }
 
     public ImmutableSet<Change.Id> getChangeIds() {
@@ -180,7 +175,7 @@
       // However, do NOT expose that ChangeData directly, as it is way out of
       // date by this point.
       ChangeData cd = checkNotNull(changes.get(id), "ChangeData for %s", id);
-      return checkNotNull(cd.getSubmitRecords(),
+      return checkNotNull(cd.getSubmitRecords(SUBMIT_RULE_OPTIONS),
           "getSubmitRecord only valid after submit rules are evalutated");
     }
 
@@ -230,6 +225,8 @@
   private CommitStatus commits;
   private ReviewDb db;
   private SubmitInput submitInput;
+  private Set<Project.NameKey> allProjects;
+  private boolean dryrun;
 
   @Inject
   MergeOp(ChangeMessagesUtil cmUtil,
@@ -257,19 +254,6 @@
     orm.close();
   }
 
-  private static Optional<SubmitRecord> findOkRecord(
-      Collection<SubmitRecord> in) {
-    if (in == null) {
-      return Optional.absent();
-    }
-    return Iterables.tryFind(in, new Predicate<SubmitRecord>() {
-      @Override
-      public boolean apply(SubmitRecord input) {
-        return input.status == SubmitRecord.Status.OK;
-      }
-    });
-  }
-
   public static void checkSubmitRule(ChangeData cd)
       throws ResourceConflictException, OrmException {
     PatchSet patchSet = cd.currentPatchSet();
@@ -278,7 +262,7 @@
           "missing current patch set for change " + cd.getId());
     }
     List<SubmitRecord> results = getSubmitRecords(cd);
-    if (findOkRecord(results).isPresent()) {
+    if (SubmitRecord.findOkRecord(results).isPresent()) {
       // Rules supplied a valid solution.
       return;
     } else if (results.isEmpty()) {
@@ -318,12 +302,7 @@
 
   private static List<SubmitRecord> getSubmitRecords(ChangeData cd)
       throws OrmException {
-    List<SubmitRecord> results = cd.getSubmitRecords();
-    if (results == null) {
-      results = new SubmitRuleEvaluator(cd).evaluate();
-      cd.setSubmitRecords(results);
-    }
-    return results;
+    return cd.submitRecords(SUBMIT_RULE_OPTIONS);
   }
 
   private static String describeLabels(ChangeData cd,
@@ -396,14 +375,30 @@
       SubmitRecord forced = new SubmitRecord();
       forced.status = SubmitRecord.Status.FORCED;
       records.add(forced);
-      cd.setSubmitRecords(records);
+      cd.setSubmitRecords(SUBMIT_RULE_OPTIONS, records);
     }
   }
 
+  /**
+   * Merges the given change.
+   *
+   * Depending on the server configuration, more changes may be affected, e.g.
+   * by submission of a topic or via superproject subscriptions. All affected
+   * changes are integrated using the projects integration strategy.
+   *
+   * @param db the review database.
+   * @param change the change to be merged.
+   * @param caller the identity of the caller
+   * @param checkSubmitRules whether the prolog submit rules should be evaluated
+   * @param submitInput parameters regarding the merge
+   * @throws OrmException an error occurred reading or writing the database.
+   * @throws RestApiException if an error occurred.
+   */
   public void merge(ReviewDb db, Change change, IdentifiedUser caller,
-      boolean checkSubmitRules, SubmitInput submitInput)
+      boolean checkSubmitRules, SubmitInput submitInput, boolean dryrun)
       throws OrmException, RestApiException {
     this.submitInput = submitInput;
+    this.dryrun = dryrun;
     this.caller = caller;
     this.ts = TimeUtil.nowTs();
     submissionId = RequestId.forChange(change);
@@ -412,7 +407,8 @@
 
     logDebug("Beginning integration of {}", change);
     try {
-      ChangeSet cs = mergeSuperSet.completeChangeSet(db, change, caller);
+      ChangeSet cs = mergeSuperSet.setMergeOpRepoManager(orm)
+          .completeChangeSet(db, change, caller);
       checkState(cs.ids().contains(change.getId()),
           "change %s missing from %s", change.getId(), cs);
       if (cs.furtherHiddenChanges()) {
@@ -447,39 +443,33 @@
         "cannot integrate hidden changes into history");
     logDebug("Beginning merge attempt on {}", cs);
     Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
-    logDebug("Perform the merges");
 
-    Multimap<Project.NameKey, Branch.NameKey> br;
     Multimap<Branch.NameKey, ChangeData> cbb;
     try {
-      br = cs.branchesByProject();
       cbb = cs.changesByBranch();
     } catch (OrmException e) {
       throw new IntegrationException("Error reading changes to submit", e);
     }
-    Set<Project.NameKey> projects = br.keySet();
     Set<Branch.NameKey> branches = cbb.keySet();
-    openRepos(projects);
-
     for (Branch.NameKey branch : branches) {
-      OpenRepo or = orm.getRepo(branch.getParentKey());
-      toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
+      OpenRepo or = openRepo(branch.getParentKey());
+      if (or != null) {
+        toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
+      }
     }
     // Done checks that don't involve running submit strategies.
     commits.maybeFailVerbose();
     SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
     try {
-      List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp);
-      Set<Project.NameKey> allProjects = submoduleOp.getProjectsInOrder();
-      // in case superproject subscription is disabled, allProjects would be null
-      if (allProjects == null) {
-        allProjects = projects;
-      }
-      BatchUpdate.execute(
-          orm.batchUpdates(allProjects),
+      List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit,
+          submoduleOp, dryrun);
+      this.allProjects = submoduleOp.getProjectsInOrder();
+      BatchUpdate.execute(orm.batchUpdates(allProjects),
           new SubmitStrategyListener(submitInput, strategies, commits),
-          submissionId);
-    } catch (UpdateException | SubmoduleException e) {
+          submissionId, dryrun);
+    } catch (SubmoduleException e) {
+      throw new IntegrationException(e);
+    } catch (UpdateException e) {
       // BatchUpdate may have inadvertently wrapped an IntegrationException
       // thrown by some legacy SubmitStrategyOp code that intended the error
       // message to be user-visible. Copy the message from the wrapped
@@ -491,23 +481,25 @@
       if (e.getCause() instanceof IntegrationException) {
         msg = e.getCause().getMessage();
       } else {
-        msg = "Error submitting change" + (cs.size() != 1 ? "s" : "") + ": \n"
-            + e.getMessage();
+        msg = "Error submitting change" + (cs.size() != 1 ? "s" : "");
       }
       throw new IntegrationException(msg, e);
     }
   }
 
+  public Set<Project.NameKey> getAllProjects() {
+    return allProjects;
+  }
+
+  public MergeOpRepoManager getMergeOpRepoManager() {
+    return orm;
+  }
+
   private List<SubmitStrategy> getSubmitStrategies(
-      Map<Branch.NameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp)
-      throws IntegrationException {
+      Map<Branch.NameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp,
+      boolean dryrun) throws IntegrationException {
     List<SubmitStrategy> strategies = new ArrayList<>();
     Set<Branch.NameKey> allBranches = submoduleOp.getBranchesInOrder();
-    // in case superproject subscription is disabled, allBranches would be null
-    if (allBranches == null) {
-      allBranches = toSubmit.keySet();
-    }
-
     for (Branch.NameKey branch : allBranches) {
       OpenRepo or = orm.getRepo(branch.getParentKey());
       if (toSubmit.containsKey(branch)) {
@@ -519,9 +511,13 @@
         Set<CodeReviewCommit> commitsToSubmit = commits(submitting.changes());
         ob.mergeTip = new MergeTip(ob.oldTip, commitsToSubmit);
         SubmitStrategy strategy = createStrategy(or, ob.mergeTip, branch,
-            submitting.submitType(), ob.oldTip, submoduleOp);
+            submitting.submitType(), ob.oldTip, submoduleOp, dryrun);
         strategies.add(strategy);
         strategy.addOps(or.getUpdate(), commitsToSubmit);
+        if (submitting.submitType().equals(SubmitType.FAST_FORWARD_ONLY) &&
+            submoduleOp.hasSubscription(branch)) {
+          submoduleOp.addOp(or.getUpdate(), branch);
+        }
       } else {
         // no open change for this branch
         // add submodule triggered op into BatchUpdate
@@ -545,10 +541,12 @@
 
   private SubmitStrategy createStrategy(OpenRepo or,
       MergeTip mergeTip, Branch.NameKey destBranch, SubmitType submitType,
-      CodeReviewCommit branchTip, SubmoduleOp submoduleOp) throws IntegrationException {
+      CodeReviewCommit branchTip, SubmoduleOp submoduleOp, boolean dryrun)
+          throws IntegrationException {
     return submitStrategyFactory.create(submitType, db, or.repo, or.rw, or.ins,
         or.canMergeFlag, getAlreadyAccepted(or, branchTip), destBranch, caller,
-        mergeTip, commits, submissionId, submitInput.notify, submoduleOp);
+        mergeTip, commits, submissionId, submitInput.notify, submoduleOp,
+        dryrun);
   }
 
   private Set<RevCommit> getAlreadyAccepted(OpenRepo or,
@@ -723,19 +721,18 @@
     }
   }
 
-  private void openRepos(Collection<Project.NameKey> projects)
+  private OpenRepo openRepo(Project.NameKey project)
       throws IntegrationException {
-    for (Project.NameKey project : projects) {
-      try {
-        orm.openRepo(project, true);
-      } catch (NoSuchProjectException noProject) {
-        logWarn("Project " + noProject.project() + " no longer exists, "
-            + "abandoning open changes");
-        abandonAllOpenChangeForDeletedProject(noProject.project());
-      } catch (IOException e) {
-        throw new IntegrationException("Error opening project " + project, e);
-      }
+    try {
+      return orm.openRepo(project);
+    } catch (NoSuchProjectException noProject) {
+      logWarn("Project " + noProject.project() + " no longer exists, "
+          + "abandoning open changes");
+      abandonAllOpenChangeForDeletedProject(noProject.project());
+    } catch (IOException e) {
+      throw new IntegrationException("Error opening project " + project, e);
     }
+    return null;
   }
 
   private void abandonAllOpenChangeForDeletedProject(
@@ -755,11 +752,11 @@
 
               change.setStatus(Change.Status.ABANDONED);
 
-              ChangeMessage msg = new ChangeMessage(
-                  new ChangeMessage.Key(change.getId(),
-                      ChangeUtil.messageUUID(ctx.getDb())),
-                  null, change.getLastUpdatedOn(), change.currentPatchSetId());
-              msg.setMessage("Project was deleted.");
+              ChangeMessage msg = ChangeMessagesUtil.newMessage(
+                  ctx.getDb(), change.currentPatchSetId(),
+                  internalUserFactory.create(), change.getLastUpdatedOn(),
+                  ChangeMessagesUtil.TAG_MERGED,
+                  "Project was deleted.");
               cmUtil.addChangeMessage(ctx.getDb(),
                   ctx.getUpdate(change.currentPatchSetId()), msg);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
index fb4c2d4..5196ebe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
@@ -90,11 +90,19 @@
       return ob;
     }
 
+    public Repository getRepo() {
+      return repo;
+    }
+
     Project.NameKey getProjectName() {
       return project.getProject().getNameKey();
     }
 
-    BatchUpdate getUpdate() {
+    public CodeReviewRevWalk getCodeReviewRevWalk() {
+      return rw;
+    }
+
+    public BatchUpdate getUpdate() {
       checkState(db != null, "call setContext before getUpdate");
       if (update == null) {
         update = batchUpdateFactory.create(db, getProjectName(), caller, ts)
@@ -187,13 +195,8 @@
     return or;
   }
 
-  public OpenRepo openRepo(Project.NameKey project, boolean abortIfOpen)
+  public OpenRepo openRepo(Project.NameKey project)
       throws NoSuchProjectException, IOException {
-    if (abortIfOpen) {
-      checkState(!openRepos.containsKey(project),
-          "repo already opened: %s", project);
-    }
-
     if (openRepos.containsKey(project)) {
       return openRepos.get(project);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
index 284e9ed..751933c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Multimap;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -31,31 +33,34 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 
 /**
@@ -68,7 +73,6 @@
  * If change.submitWholeTopic is enabled, also all changes of the topic
  * and their parents are included.
  */
-@Singleton
 public class MergeSuperSet {
   private static final Logger log = LoggerFactory.getLogger(MergeOp.class);
 
@@ -77,46 +81,69 @@
     for (ChangeData cd : cs.changes()) {
       cd.reloadChange();
       cd.setPatchSets(null);
+      cd.setMergeable(null);
     }
   }
 
+  @AutoValue
+  abstract static class QueryKey {
+    private static QueryKey create(
+        Branch.NameKey branch, Iterable<String> hashes) {
+      return new AutoValue_MergeSuperSet_QueryKey(
+          branch, ImmutableSet.copyOf(hashes));
+    }
+
+    abstract Branch.NameKey branch();
+    abstract ImmutableSet<String> hashes();
+  }
+
   private final ChangeData.Factory changeDataFactory;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final GitRepositoryManager repoManager;
+  private final Provider<MergeOpRepoManager> repoManagerProvider;
   private final Config cfg;
+  private final Map<QueryKey, List<ChangeData>> queryCache;
+  private final Map<Branch.NameKey, Optional<RevCommit>> heads;
+
+  private MergeOpRepoManager orm;
+  private boolean closeOrm;
 
   @Inject
   MergeSuperSet(@GerritServerConfig Config cfg,
       ChangeData.Factory changeDataFactory,
       Provider<InternalChangeQuery> queryProvider,
-      GitRepositoryManager repoManager) {
+      Provider<MergeOpRepoManager> repoManagerProvider) {
     this.cfg = cfg;
     this.changeDataFactory = changeDataFactory;
     this.queryProvider = queryProvider;
-    this.repoManager = repoManager;
+    this.repoManagerProvider = repoManagerProvider;
+    queryCache = new HashMap<>();
+    heads = new HashMap<>();
   }
 
-  public ChangeSet completeChangeSet(ReviewDb db, Change change, CurrentUser user)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException,
-      OrmException {
-    ChangeData cd =
-        changeDataFactory.create(db, change.getProject(), change.getId());
-    cd.changeControl(user);
-    ChangeSet cs = new ChangeSet(cd, cd.changeControl().isVisible(db, cd));
-    if (Submit.wholeTopicEnabled(cfg)) {
-      return completeChangeSetIncludingTopics(db, cs, user);
-    }
-    return completeChangeSetWithoutTopic(db, cs, user);
+  public MergeSuperSet setMergeOpRepoManager(MergeOpRepoManager orm) {
+    checkState(this.orm == null);
+    this.orm = checkNotNull(orm);
+    closeOrm = false;
+    return this;
   }
 
-  private static ImmutableListMultimap<Project.NameKey, ChangeData>
-      byProject(Iterable<ChangeData> changes) throws OrmException {
-    ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
-        new ImmutableListMultimap.Builder<>();
-    for (ChangeData cd : changes) {
-      builder.put(cd.change().getProject(), cd);
+  public ChangeSet completeChangeSet(ReviewDb db, Change change,
+      CurrentUser user) throws IOException, OrmException {
+    try {
+      ChangeData cd =
+          changeDataFactory.create(db, change.getProject(), change.getId());
+      cd.changeControl(user);
+      ChangeSet cs = new ChangeSet(cd, cd.changeControl().isVisible(db, cd));
+      if (Submit.wholeTopicEnabled(cfg)) {
+        return completeChangeSetIncludingTopics(db, cs, user);
+      }
+      return completeChangeSetWithoutTopic(db, cs, user);
+    } finally {
+      if (closeOrm && orm != null) {
+        orm.close();
+        orm = null;
+      }
     }
-    return builder.build();
   }
 
   private SubmitType submitType(ChangeData cd, PatchSet ps, boolean visible)
@@ -146,94 +173,175 @@
     return str.type;
   }
 
-  private ChangeSet completeChangeSetWithoutTopic(ReviewDb db, ChangeSet changes,
-      CurrentUser user) throws MissingObjectException,
-      IncorrectObjectTypeException, IOException, OrmException {
-    List<ChangeData> visibleChanges = new ArrayList<>();
-    List<ChangeData> nonVisibleChanges = new ArrayList<>();
+  private static ImmutableListMultimap<Branch.NameKey, ChangeData>
+      byBranch(Iterable<ChangeData> changes) throws OrmException {
+    ImmutableListMultimap.Builder<Branch.NameKey, ChangeData> builder =
+        ImmutableListMultimap.builder();
+    for (ChangeData cd : changes) {
+      builder.put(cd.change().getDest(), cd);
+    }
+    return builder.build();
+  }
 
-    Multimap<Project.NameKey, ChangeData> pc =
-        byProject(
-            Iterables.concat(changes.changes(), changes.nonVisibleChanges()));
-    for (Project.NameKey project : pc.keySet()) {
-      try (Repository repo = repoManager.openRepository(project);
-           RevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-        for (ChangeData cd : pc.get(project)) {
-          checkState(cd.hasChangeControl(),
-              "completeChangeSet forgot to set changeControl for current user"
-              + " at ChangeData creation time");
-          boolean visible = changes.ids().contains(cd.getId());
-          if (visible && !cd.changeControl().isVisible(db, cd)) {
-            // We thought the change was visible, but it isn't.
-            // This can happen if the ACL changes during the
-            // completeChangeSet computation, for example.
-            visible = false;
-          }
-          List<ChangeData> dest = visible ? visibleChanges : nonVisibleChanges;
+  private Set<String> walkChangesByHashes(Collection<RevCommit> sourceCommits,
+      Set<String> ignoreHashes, OpenRepo or, Branch.NameKey b)
+      throws IOException {
+    Set<String> destHashes = new HashSet<>();
+    or.rw.reset();
+    markHeadUninteresting(or, b);
+    for (RevCommit c : sourceCommits) {
+      String name = c.name();
+      if (ignoreHashes.contains(name)) {
+        continue;
+      }
+      destHashes.add(name);
+      or.rw.markStart(c);
+    }
+    for (RevCommit c : or.rw) {
+      String name = c.name();
+      if (ignoreHashes.contains(name)) {
+        continue;
+      }
+      destHashes.add(name);
+    }
 
-          // Pick a revision to use for traversal.  If any of the patch sets
-          // is visible, we use the most recent one.  Otherwise, use the current
-          // patch set.
-          PatchSet ps = cd.currentPatchSet();
-          boolean visiblePatchSet = visible;
-          if (!cd.changeControl().isPatchVisible(ps, cd)) {
-            Iterable<PatchSet> visiblePatchSets = cd.visiblePatchSets();
-            if (Iterables.isEmpty(visiblePatchSets)) {
-              visiblePatchSet = false;
-            } else {
-              ps = Iterables.getLast(visiblePatchSets);
-            }
-          }
+    return destHashes;
+  }
 
-          if (submitType(cd, ps, visiblePatchSet) == SubmitType.CHERRY_PICK) {
-            dest.add(cd);
-            continue;
-          }
+  private ChangeSet completeChangeSetWithoutTopic(ReviewDb db,
+      ChangeSet changes, CurrentUser user) throws IOException, OrmException {
+    Collection<ChangeData> visibleChanges = new ArrayList<>();
+    Collection<ChangeData> nonVisibleChanges = new ArrayList<>();
 
-          // Get the underlying git commit object
-          String objIdStr = ps.getRevision().get();
-          RevCommit commit = rw.parseCommit(ObjectId.fromString(objIdStr));
+    // For each target branch we run a separate rev walk to find open changes
+    // reachable from changes already in the merge super set.
+    ImmutableListMultimap<Branch.NameKey, ChangeData> bc = byBranch(
+        Iterables.concat(changes.changes(), changes.nonVisibleChanges()));
+    for (Branch.NameKey b : bc.keySet()) {
+      OpenRepo or = getRepo(b.getParentKey());
+      List<RevCommit> visibleCommits = new ArrayList<>();
+      List<RevCommit> nonVisibleCommits = new ArrayList<>();
+      for (ChangeData cd : bc.get(b)) {
+        checkState(cd.hasChangeControl(),
+            "completeChangeSet forgot to set changeControl for current user"
+                + " at ChangeData creation time");
 
-          // Collect unmerged ancestors
-          Branch.NameKey destBranch = cd.change().getDest();
-          repo.getRefDatabase().refresh();
-          Ref ref = repo.getRefDatabase().getRef(destBranch.get());
+        boolean visible = changes.ids().contains(cd.getId());
+        if (visible && !cd.changeControl().isVisible(db, cd)) {
+          // We thought the change was visible, but it isn't.
+          // This can happen if the ACL changes during the
+          // completeChangeSet computation, for example.
+          visible = false;
+        }
+        Collection<RevCommit> toWalk = visible ?
+            visibleCommits : nonVisibleCommits;
 
-          rw.reset();
-          rw.sort(RevSort.TOPO);
-          rw.markStart(commit);
-          if (ref != null) {
-            RevCommit head = rw.parseCommit(ref.getObjectId());
-            rw.markUninteresting(head);
-          }
-
-          List<String> hashes = new ArrayList<>();
-          // Always include the input, even if merged. This allows
-          // SubmitStrategyOp to correct the situation later, assuming it gets
-          // returned by byCommitsOnBranchNotMerged below.
-          hashes.add(objIdStr);
-          for (RevCommit c : rw) {
-            if (!c.equals(commit)) {
-              hashes.add(c.name());
-            }
-          }
-
-          if (!hashes.isEmpty()) {
-            Iterable<ChangeData> destChanges = query()
-                .byCommitsOnBranchNotMerged(
-                  repo, db, cd.change().getDest(), hashes);
-            for (ChangeData chd : destChanges) {
-              chd.changeControl(user);
-              dest.add(chd);
-            }
+        // Pick a revision to use for traversal.  If any of the patch sets
+        // is visible, we use the most recent one.  Otherwise, use the current
+        // patch set.
+        PatchSet ps = cd.currentPatchSet();
+        boolean visiblePatchSet = visible;
+        if (!cd.changeControl().isPatchVisible(ps, cd)) {
+          Iterable<PatchSet> visiblePatchSets = cd.visiblePatchSets();
+          if (Iterables.isEmpty(visiblePatchSets)) {
+            visiblePatchSet = false;
+          } else {
+            ps = Iterables.getLast(visiblePatchSets);
           }
         }
+
+        if (submitType(cd, ps, visiblePatchSet) == SubmitType.CHERRY_PICK) {
+          if (visible) {
+            visibleChanges.add(cd);
+          } else {
+            nonVisibleChanges.add(cd);
+          }
+
+          continue;
+        }
+
+        // Get the underlying git commit object
+        String objIdStr = ps.getRevision().get();
+        RevCommit commit = or.rw.parseCommit(ObjectId.fromString(objIdStr));
+
+        // Always include the input, even if merged. This allows
+        // SubmitStrategyOp to correct the situation later, assuming it gets
+        // returned by byCommitsOnBranchNotMerged below.
+        toWalk.add(commit);
       }
+
+      Set<String> emptySet = Collections.emptySet();
+      Set<String> visibleHashes =
+          walkChangesByHashes(visibleCommits, emptySet, or, b);
+
+      List<ChangeData> cds =
+          byCommitsOnBranchNotMerged(or, db, user, b, visibleHashes);
+      for (ChangeData chd : cds) {
+        chd.changeControl(user);
+        visibleChanges.add(chd);
+      }
+
+      Set<String> nonVisibleHashes =
+          walkChangesByHashes(nonVisibleCommits, visibleHashes, or, b);
+      Iterables.addAll(nonVisibleChanges,
+          byCommitsOnBranchNotMerged(or, db, user, b, nonVisibleHashes));
     }
 
     return new ChangeSet(visibleChanges, nonVisibleChanges);
   }
 
+  private OpenRepo getRepo(Project.NameKey project) throws IOException {
+    if (orm == null) {
+      orm = repoManagerProvider.get();
+      closeOrm = true;
+    }
+    try {
+      OpenRepo or = orm.openRepo(project);
+      checkState(or.rw.hasRevSort(RevSort.TOPO));
+      return or;
+    } catch (NoSuchProjectException e) {
+      throw new IOException(e);
+    }
+  }
+
+  private void markHeadUninteresting(OpenRepo or, Branch.NameKey b)
+      throws IOException {
+    Optional<RevCommit> head = heads.get(b);
+    if (head == null) {
+      Ref ref = or.repo.getRefDatabase().exactRef(b.get());
+      head = ref != null
+          ? Optional.of(or.rw.parseCommit(ref.getObjectId()))
+          : Optional.empty();
+      heads.put(b, head);
+    }
+    if (head.isPresent()) {
+      or.rw.markUninteresting(head.get());
+    }
+  }
+
+  private List<ChangeData> byCommitsOnBranchNotMerged(OpenRepo or, ReviewDb db,
+      CurrentUser user, Branch.NameKey branch, Set<String> hashes)
+      throws OrmException, IOException {
+    if (hashes.isEmpty()) {
+      return ImmutableList.of();
+    }
+    QueryKey k = QueryKey.create(branch, hashes);
+    List<ChangeData> cached = queryCache.get(k);
+    if (cached != null) {
+      return cached;
+    }
+
+    List<ChangeData> result = new ArrayList<>();
+    Iterable<ChangeData> destChanges = query()
+        .byCommitsOnBranchNotMerged(or.repo, db, branch, hashes);
+    for (ChangeData chd : destChanges) {
+      chd.changeControl(user);
+      result.add(chd);
+    }
+    queryCache.put(k, result);
+    return result;
+  }
+
   /**
    * Completes {@code cs} with any additional changes from its topics
    * <p>
@@ -261,11 +369,19 @@
         continue;
       }
       for (ChangeData topicCd : query().byTopicOpen(topic)) {
-        topicCd.changeControl(user);
-        if (topicCd.changeControl().isVisible(db, topicCd)) {
-          visibleChanges.add(topicCd);
-        } else {
-          nonVisibleChanges.add(topicCd);
+        try {
+          topicCd.changeControl(user);
+          if (topicCd.changeControl().isVisible(db, topicCd)) {
+            visibleChanges.add(topicCd);
+          } else {
+            nonVisibleChanges.add(topicCd);
+          }
+        } catch (OrmException e) {
+          if (e.getCause() instanceof NoSuchChangeException) {
+            // Ignore and skip this change
+          } else {
+            throw e;
+          }
         }
       }
       topicsSeen.add(topic);
@@ -288,8 +404,7 @@
 
   private ChangeSet completeChangeSetIncludingTopics(
       ReviewDb db, ChangeSet changes, CurrentUser user)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException,
-      OrmException {
+      throws IOException, OrmException {
     Set<String> topicsSeen = new HashSet<>();
     Set<String> visibleTopicsSeen = new HashSet<>();
     int oldSeen;
@@ -307,13 +422,15 @@
   }
 
   private InternalChangeQuery query() {
-    // Request fields required for completing the ChangeSet without having to
-    // touch the database. This provides reasonable performance when loading the
-    // change screen; callers that care about reading the latest value of these
-    // fields should clear them explicitly using reloadChanges().
+    // Request fields required for completing the ChangeSet and converting to
+    // ChangeInfo without having to touch the database or opening the repository
+    // more than necessary. This provides reasonable performance when loading
+    // the change screen; callers that care about reading the latest value of
+    // these fields should clear them explicitly using reloadChanges().
     Set<String> fields = ImmutableSet.of(
         ChangeField.CHANGE.getName(),
-        ChangeField.PATCH_SET.getName());
+        ChangeField.PATCH_SET.getName(),
+        ChangeField.MERGEABLE.getName());
     return queryProvider.get().setRequestedFields(fields);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
index 89ec1d6..90edfb1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
@@ -16,9 +16,9 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
+import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
@@ -184,13 +184,13 @@
   public CodeReviewCommit createCherryPickFromCommit(Repository repo,
       ObjectInserter inserter, RevCommit mergeTip, RevCommit originalCommit,
       PersonIdent cherryPickCommitterIdent, String commitMsg,
-      CodeReviewRevWalk rw)
+      CodeReviewRevWalk rw, int parentIndex)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
       MergeIdenticalTreeException, MergeConflictException {
 
     final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
 
-    m.setBase(originalCommit.getParent(0));
+    m.setBase(originalCommit.getParent(parentIndex));
     if (m.merge(mergeTip, originalCommit)) {
       ObjectId tree = m.getResultTreeId();
       if (tree.equals(mergeTip.getTree())) {
@@ -213,7 +213,8 @@
       PersonIdent committerIndent, String commitMsg, RevWalk rw)
       throws IOException, MergeIdenticalTreeException, MergeConflictException {
 
-    if (rw.isMergedInto(originalCommit, mergeTip)) {
+    if (!MergeStrategy.THEIRS.getName().equals(mergeStrategy) &&
+        rw.isMergedInto(originalCommit, mergeTip)) {
       throw new ChangeAlreadyMergedException(
           "'" + originalCommit.getName() + "' has already been merged");
     }
@@ -598,14 +599,10 @@
           Joiner.on("', '").join(topics));
     } else {
       return String.format("Merge changes %s%s",
-          Joiner.on(',').join(Iterables.transform(
-              Iterables.limit(merged, 5),
-              new Function<CodeReviewCommit, String>() {
-                @Override
-                public String apply(CodeReviewCommit in) {
-                  return in.change().getKey().abbreviate();
-                }
-              })),
+          FluentIterable.from(merged)
+              .limit(5)
+              .transform(c -> c.change().getKey().abbreviate())
+              .join(Joiner.on(',')),
           merged.size() > 5 ? ", ..." : "");
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
index 2ccc849..48bdd13 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -22,13 +22,13 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.mail.MergedSender;
+import com.google.gerrit.server.mail.send.MergedSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.util.RequestScopePropagator;
@@ -149,19 +149,14 @@
       }
     }
     msgBuf.append(".");
-    ChangeMessage msg = new ChangeMessage(
-        new ChangeMessage.Key(change.getId(),
-            ChangeUtil.messageUUID(ctx.getDb())),
-        ctx.getAccountId(), ctx.getWhen(), psId);
-    msg.setMessage(msgBuf.toString());
+    ChangeMessage msg = ChangeMessagesUtil.newMessage(
+        ctx.getDb(), psId, ctx.getUser(), ctx.getWhen(), msgBuf.toString(),
+        ChangeMessagesUtil.TAG_MERGED);
     cmUtil.addChangeMessage(ctx.getDb(), update, msg);
 
-    PatchSetApproval submitter = new PatchSetApproval(
-          new PatchSetApproval.Key(
-              change.currentPatchSetId(),
-              ctx.getAccountId(),
-              LabelId.legacySubmit()),
-              (short) 1, ctx.getWhen());
+    PatchSetApproval submitter = ApprovalsUtil.newApproval(
+        change.currentPatchSetId(), ctx.getUser(), LabelId.legacySubmit(),
+        1, ctx.getWhen());
     update.putApproval(submitter.getLabel(), submitter.getValue());
     ctx.getDb().patchSetApprovals().upsert(
         Collections.singleton(submitter));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
index 3b1fa09..4f143ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
@@ -91,7 +91,7 @@
   private static final String PROJECT = "project";
   private static final String KEY_DESCRIPTION = "description";
 
-  private static final String ACCESS = "access";
+  public static final String ACCESS = "access";
   private static final String KEY_INHERIT_FROM = "inheritFrom";
   private static final String KEY_GROUP_PERMISSIONS = "exclusiveGroupPermissions";
 
@@ -151,15 +151,18 @@
   private static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
   private static final String KEY_VALUE = "value";
   private static final String KEY_CAN_OVERRIDE = "canOverride";
-  private static final String KEY_Branch = "branch";
+  private static final String KEY_BRANCH = "branch";
   private static final Set<String> LABEL_FUNCTIONS = ImmutableSet.of(
       "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp", "PatchSetLock");
 
+  private static final String LEGACY_PERMISSION_PUSH_TAG = "pushTag";
+  private static final String LEGACY_PERMISSION_PUSH_SIGNED_TAG = "pushSignedTag";
+
   private static final String PLUGIN = "plugin";
 
-  private static final SubmitType defaultSubmitAction =
+  private static final SubmitType DEFAULT_SUBMIT_ACTION =
       SubmitType.MERGE_IF_NECESSARY;
-  private static final ProjectState defaultStateValue =
+  private static final ProjectState DEFAULT_STATE_VALUE =
       ProjectState.ACTIVE;
 
   private Project.NameKey projectName;
@@ -180,6 +183,7 @@
   private Map<String, Config> pluginConfigs;
   private boolean checkReceivedObjects;
   private Set<String> sectionsWithUnknownPermissions;
+  private boolean hasLegacyPermissions;
 
   public static ProjectConfig read(MetaDataUpdate update) throws IOException,
       ConfigInvalidException {
@@ -267,6 +271,10 @@
     return branchOrderSection;
   }
 
+  public Map<Project.NameKey, SubscribeSection> getSubscribeSections() {
+    return subscribeSections;
+  }
+
   public Collection<SubscribeSection> getSubscribeSections(
       Branch.NameKey branch) {
     Collection<SubscribeSection> ret = new ArrayList<>();
@@ -495,9 +503,10 @@
     p.setRejectImplicitMerges(getEnum(rc, RECEIVE, null,
         KEY_REJECT_IMPLICIT_MERGES, InheritableBoolean.INHERIT));
 
-    p.setSubmitType(getEnum(rc, SUBMIT, null, KEY_ACTION, defaultSubmitAction));
+    p.setSubmitType(getEnum(rc, SUBMIT, null, KEY_ACTION,
+        DEFAULT_SUBMIT_ACTION));
     p.setUseContentMerge(getEnum(rc, SUBMIT, null, KEY_MERGE_CONTENT, InheritableBoolean.INHERIT));
-    p.setState(getEnum(rc, PROJECT, null, KEY_STATE, defaultStateValue));
+    p.setState(getEnum(rc, PROJECT, null, KEY_STATE, DEFAULT_STATE_VALUE));
 
     p.setDefaultDashboard(rc.getString(DASHBOARD, null, KEY_DEFAULT));
     p.setLocalDefaultDashboard(rc.getString(DASHBOARD, null, KEY_LOCAL_DEFAULT));
@@ -627,6 +636,7 @@
 
         for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
           for (String n : varName.split("[, \t]{1,}")) {
+            n = convertLegacyPermission(n);
             if (isPermission(n)) {
               as.getPermission(n, true).setExclusiveGroup(true);
             }
@@ -634,10 +644,11 @@
         }
 
         for (String varName : rc.getNames(ACCESS, refName)) {
-          if (isPermission(varName)) {
-            Permission perm = as.getPermission(varName, true);
+          String convertedName = convertLegacyPermission(varName);
+          if (isPermission(convertedName)) {
+            Permission perm = as.getPermission(convertedName, true);
             loadPermissionRules(rc, ACCESS, refName, varName, groupsByName,
-                perm, Permission.hasRange(varName));
+                perm, Permission.hasRange(convertedName));
           } else {
             sectionsWithUnknownPermissions.add(as.getName());
           }
@@ -805,7 +816,7 @@
       label.setCanOverride(
           rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE,
               LabelType.DEF_CAN_OVERRIDE));
-      label.setRefPatterns(getStringListOrNull(rc, LABEL, name, KEY_Branch));
+      label.setRefPatterns(getStringListOrNull(rc, LABEL, name, KEY_BRANCH));
       labelSections.put(name, label);
     }
   }
@@ -904,7 +915,8 @@
   }
 
   private void readGroupList() throws IOException {
-    groupList = GroupList.parse(readUTF8(GroupList.FILE_NAME), this);
+    groupList = GroupList.parse(
+        projectName, readUTF8(GroupList.FILE_NAME), this);
   }
 
   private Map<String, GroupReference> mapGroupReferences() {
@@ -946,10 +958,10 @@
     set(rc, RECEIVE, null, KEY_REJECT_IMPLICIT_MERGES,
         p.getRejectImplicitMerges(), InheritableBoolean.INHERIT);
 
-    set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), defaultSubmitAction);
+    set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), DEFAULT_SUBMIT_ACTION);
     set(rc, SUBMIT, null, KEY_MERGE_CONTENT, p.getUseContentMerge(), InheritableBoolean.INHERIT);
 
-    set(rc, PROJECT, null, KEY_STATE, p.getState(), defaultStateValue);
+    set(rc, PROJECT, null, KEY_STATE, p.getState(), DEFAULT_STATE_VALUE);
 
     set(rc, DASHBOARD, null, KEY_DEFAULT, p.getDefaultDashboard());
     set(rc, DASHBOARD, null, KEY_LOCAL_DEFAULT, p.getLocalDefaultDashboard());
@@ -1147,7 +1159,8 @@
       }
 
       for (String varName : rc.getNames(ACCESS, refName)) {
-        if (isPermission(varName) && !have.contains(varName.toLowerCase())) {
+        if (isPermission(convertLegacyPermission(varName))
+            && !have.contains(varName.toLowerCase())) {
           rc.unset(ACCESS, refName, varName);
         }
       }
@@ -1248,14 +1261,19 @@
   private void saveSubscribeSections(Config rc) {
     for (Project.NameKey p : subscribeSections.keySet()) {
       SubscribeSection s = subscribeSections.get(p);
+      List<String> matchings = new ArrayList<>();
       for (RefSpec r : s.getMatchingRefSpecs()) {
-        rc.setString(SUBSCRIBE_SECTION, p.get(),
-            SUBSCRIBE_MATCH_REFS, r.toString());
+        matchings.add(r.toString());
       }
+      rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MATCH_REFS,
+          matchings);
+
+      List<String> multimatchs = new ArrayList<>();
       for (RefSpec r : s.getMultiMatchRefSpecs()) {
-        rc.setString(SUBSCRIBE_SECTION, p.get(),
-            SUBSCRIBE_MULTI_MATCH_REFS, r.toString());
+        multimatchs.add(r.toString());
       }
+      rc.setStringList(SUBSCRIBE_SECTION, p.get(),
+          SUBSCRIBE_MULTI_MATCH_REFS, multimatchs);
     }
   }
 
@@ -1282,4 +1300,21 @@
     Collections.sort(r);
     return r;
   }
+
+  public boolean hasLegacyPermissions() {
+    return hasLegacyPermissions;
+  }
+
+  private String convertLegacyPermission(String permissionName) {
+    switch(permissionName) {
+      case LEGACY_PERMISSION_PUSH_TAG:
+        hasLegacyPermissions = true;
+        return Permission.CREATE_TAG;
+      case LEGACY_PERMISSION_PUSH_SIGNED_TAG:
+        hasLegacyPermissions = true;
+        return Permission.CREATE_SIGNED_TAG;
+      default:
+        return permissionName;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index 7fadae0..74362b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -21,6 +21,9 @@
 import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
 import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
+import static java.util.Comparator.comparingInt;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 import static org.eclipse.jgit.lib.RefDatabase.ALL;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
@@ -30,24 +33,19 @@
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
 
 import com.google.common.base.Function;
-import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.BiMap;
-import com.google.common.collect.Collections2;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Ordering;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
@@ -103,6 +101,7 @@
 import com.google.gerrit.server.git.validators.RefOperationValidationException;
 import com.google.gerrit.server.git.validators.RefOperationValidators;
 import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -169,6 +168,7 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import java.util.regex.Matcher;
@@ -315,6 +315,8 @@
   private final RequestId receiveId;
   private MagicBranchInput magicBranch;
   private boolean newChangeForAllNotInTarget;
+  private final ListMultimap<String, String> pushOptions =
+      LinkedListMultimap.create();
 
   private List<CreateRequest> newChanges = Collections.emptyList();
   private final Map<Change.Id, ReplaceRequest> replaceByChange =
@@ -332,6 +334,7 @@
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final NotesMigration notesMigration;
   private final ChangeEditUtil editUtil;
+  private final ChangeIndexer indexer;
 
   private final List<ValidationMessage> messages = new ArrayList<>();
   private ListMultimap<Error, String> errors = LinkedListMultimap.create();
@@ -376,6 +379,7 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       NotesMigration notesMigration,
       ChangeEditUtil editUtil,
+      ChangeIndexer indexer,
       BatchUpdate.Factory batchUpdateFactory,
       SetHashtagsOp.Factory hashtagsFactory,
       ReplaceOp.Factory replaceOpFactory,
@@ -423,6 +427,7 @@
     this.notesMigration = notesMigration;
 
     this.editUtil = editUtil;
+    this.indexer = indexer;
 
     this.messageSender = new ReceivePackMessageSender();
 
@@ -490,6 +495,7 @@
     advHooks.add(new HackPushNegotiateHook());
     rp.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
     rp.setPostReceiveHook(lazyPostReceive.get());
+    rp.setAllowPushOptions(true);
   }
 
   public void init() {
@@ -677,14 +683,9 @@
   }
 
   private void reportMessages() {
-    Iterable<CreateRequest> created =
-        Iterables.filter(newChanges, new Predicate<CreateRequest>() {
-          @Override
-          public boolean apply(CreateRequest input) {
-            return input.change != null;
-          }
-        });
-    if (!Iterables.isEmpty(created)) {
+    List<CreateRequest> created =
+        newChanges.stream().filter(r -> r.change != null).collect(toList());
+    if (!created.isEmpty()) {
       addMessage("");
       addMessage("New Changes:");
       for (CreateRequest c : created) {
@@ -695,21 +696,10 @@
       addMessage("");
     }
 
-    List<ReplaceRequest> updated = FluentIterable
-        .from(replaceByChange.values())
-        .filter(new Predicate<ReplaceRequest>() {
-          @Override
-          public boolean apply(ReplaceRequest input) {
-            return !input.skip && input.inputCommand.getResult() == OK;
-          }
-        })
-        .toSortedList(Ordering.natural().onResultOf(
-            new Function<ReplaceRequest, Integer>() {
-              @Override
-              public Integer apply(ReplaceRequest in) {
-                return in.notes.getChangeId().get();
-              }
-            }));
+    List<ReplaceRequest> updated = replaceByChange.values().stream()
+        .filter(r -> !r.skip && r.inputCommand.getResult() == OK)
+        .sorted(comparingInt(r -> r.notes.getChangeId().get()))
+        .collect(toList());
     if (!updated.isEmpty()) {
       addMessage("");
       addMessage("Updated Changes:");
@@ -827,7 +817,7 @@
       // One or more new references failed to create. Assume the
       // system isn't working correctly anymore and abort.
       reject(magicBranch.cmd, "Unable to create changes: "
-          + Joiner.on(' ').join(lastCreateChangeErrors));
+          + lastCreateChangeErrors.stream().collect(joining(" ")));
       logError(String.format(
           "Only %d of %d new change refs created in %s; aborting",
           okToInsert, replaceCount + newChanges.size(), project.getName()));
@@ -915,6 +905,18 @@
   }
 
   private void parseCommands(Collection<ReceiveCommand> commands) {
+    List<String> optionList = rp.getPushOptions();
+    if (optionList != null) {
+      for (String option : optionList) {
+        int e = option.indexOf('=');
+        if (e > 0) {
+          pushOptions.put(option.substring(0, e), option.substring(e + 1));
+        } else {
+          pushOptions.put(option, "");
+        }
+      }
+    }
+
     logDebug("Parsing {} commands", commands.size());
     for (ReceiveCommand cmd : commands) {
       if (cmd.getResult() != NOT_ATTEMPTED) {
@@ -1040,11 +1042,12 @@
                         .getPluginConfig(e.getPluginName())
                         .getString(e.getExportName());
                 if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
-                  List<String> l =
-                      Arrays.asList(projectControl.getProjectState()
-                          .getConfig().getPluginConfig(e.getPluginName())
-                          .getStringList(e.getExportName()));
-                  oldValue = Joiner.on("\n").join(l);
+                  oldValue =
+                    Arrays.stream(
+                        projectControl.getProjectState()
+                            .getConfig().getPluginConfig(e.getPluginName())
+                            .getStringList(e.getExportName()))
+                      .collect(joining("\n"));
                 }
 
                 if ((value == null ? oldValue != null : !value.equals(oldValue)) &&
@@ -1238,6 +1241,9 @@
     @Option(name = "--submit", usage = "immediately submit the change")
     boolean submit;
 
+    @Option(name = "--merged", usage = "create single change for a merged commit")
+    boolean merged;
+
     @Option(name = "--notify",
         usage = "Notify handling that defines to whom email notifications "
             + "should be sent. Allowed values are NONE, OWNER, "
@@ -1305,14 +1311,14 @@
       return new MailRecipients(reviewer, cc);
     }
 
-    String parse(CmdLineParser clp, Repository repo, Set<String> refs)
-        throws CmdLineException {
+    String parse(CmdLineParser clp, Repository repo, Set<String> refs,
+        ListMultimap<String, String> pushOptions) throws CmdLineException {
       String ref = RefNames.fullName(
           MagicBranch.getDestBranchName(cmd.getRefName()));
 
+      ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
       int optionStart = ref.indexOf('%');
       if (0 < optionStart) {
-        ListMultimap<String, String> options = LinkedListMultimap.create();
         for (String s : COMMAS.split(ref.substring(optionStart + 1))) {
           int e = s.indexOf('=');
           if (0 < e) {
@@ -1321,10 +1327,13 @@
             options.put(s, "");
           }
         }
-        clp.parseOptionMap(options);
         ref = ref.substring(0, optionStart);
       }
 
+      if (!options.isEmpty()) {
+        clp.parseOptionMap(options);
+      }
+
       // Split the destination branch by branch and topic. The topic
       // suffix is entirely optional, so it might not even exist.
       String head = readHEAD(repo);
@@ -1347,6 +1356,19 @@
     }
   }
 
+  /**
+   * Gets an unmodifiable view of the pushOptions.
+   * <p>
+   * The collection is empty if the client does not support push options, or if
+   * the client did not send any options.
+   *
+   * @return an unmodifiable view of pushOptions.
+   */
+  @Nullable
+  public ListMultimap<String, String> getPushOptions() {
+    return ImmutableListMultimap.copyOf(pushOptions);
+  }
+
   private void parseMagicBranch(ReceiveCommand cmd) {
     // Permit exactly one new change request per push.
     if (magicBranch != null) {
@@ -1362,8 +1384,10 @@
     String ref;
     CmdLineParser clp = optionParserFactory.create(magicBranch);
     magicBranch.clp = clp;
+
     try {
-      ref = magicBranch.parse(clp, repo, rp.getAdvertisedRefs().keySet());
+      ref = magicBranch.parse(
+          clp, repo, rp.getAdvertisedRefs().keySet(), pushOptions);
     } catch (CmdLineException e) {
       if (!clp.wasHelpRequestedByOption()) {
         logDebug("Invalid branch syntax");
@@ -1408,7 +1432,8 @@
         errors.put(Error.CODE_REVIEW, ref);
         reject(cmd, "draft workflow is disabled");
         return;
-      } else if (projectControl.controlForRef("refs/drafts/" + ref)
+      } else if (projectControl
+          .controlForRef(MagicBranch.NEW_DRAFT_CHANGE + ref)
           .isBlocked(Permission.PUSH)) {
         errors.put(Error.CODE_REVIEW, ref);
         reject(cmd, "cannot upload drafts");
@@ -1428,7 +1453,7 @@
     }
 
     if (magicBranch.submit && !projectControl.controlForRef(
-        MagicBranch.NEW_CHANGE + ref).canSubmit()) {
+        MagicBranch.NEW_CHANGE + ref).canSubmit(true)) {
       reject(cmd, "submit not allowed");
       return;
     }
@@ -1444,56 +1469,71 @@
       return;
     }
 
-    // If tip is a merge commit, or the root commit or
-    // if %base was specified, ignore newChangeForAllNotInTarget
-    if (tip.getParentCount() > 1
-        || magicBranch.base != null
-        || tip.getParentCount() == 0) {
-      logDebug("Forcing newChangeForAllNotInTarget = false");
-      newChangeForAllNotInTarget = false;
-    }
-
-    if (magicBranch.base != null) {
-      logDebug("Handling %base: {}", magicBranch.base);
-      magicBranch.baseCommit = Lists.newArrayListWithCapacity(
-          magicBranch.base.size());
-      for (ObjectId id : magicBranch.base) {
-        try {
-          magicBranch.baseCommit.add(walk.parseCommit(id));
-        } catch (IncorrectObjectTypeException notCommit) {
-          reject(cmd, "base must be a commit");
+    String destBranch = magicBranch.dest.get();
+    try {
+      if (magicBranch.merged) {
+        if (magicBranch.draft) {
+          reject(cmd, "cannot be draft & merged");
           return;
-        } catch (MissingObjectException e) {
-          reject(cmd, "base not found");
+        }
+        if (magicBranch.base != null) {
+          reject(cmd, "cannot use merged with base");
           return;
-        } catch (IOException e) {
-          logWarn(String.format(
-              "Project %s cannot read %s",
-              project.getName(), id.name()), e);
-          reject(cmd, "internal server error");
+        }
+        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
+        if (branchTip == null) {
+          return; // readBranchTip already rejected cmd.
+        }
+        if (!walk.isMergedInto(tip, branchTip)) {
+          reject(cmd, "not merged into branch");
           return;
         }
       }
-    } else if (newChangeForAllNotInTarget) {
-      logDebug("Handling newChangeForAllNotInTarget");
-      String destBranch = magicBranch.dest.get();
-      try {
-        Ref r = repo.getRefDatabase().exactRef(destBranch);
-        if (r == null) {
-          reject(cmd, destBranch + " not found");
-          return;
-        }
 
-        ObjectId baseHead = r.getObjectId();
-        magicBranch.baseCommit =
-            Collections.singletonList(walk.parseCommit(baseHead));
+      // If tip is a merge commit, or the root commit or
+      // if %base or %merged was specified, ignore newChangeForAllNotInTarget.
+      if (tip.getParentCount() > 1
+          || magicBranch.base != null
+          || magicBranch.merged
+          || tip.getParentCount() == 0) {
+        logDebug("Forcing newChangeForAllNotInTarget = false");
+        newChangeForAllNotInTarget = false;
+      }
+
+      if (magicBranch.base != null) {
+        logDebug("Handling %base: {}", magicBranch.base);
+        magicBranch.baseCommit = Lists.newArrayListWithCapacity(
+            magicBranch.base.size());
+        for (ObjectId id : magicBranch.base) {
+          try {
+            magicBranch.baseCommit.add(walk.parseCommit(id));
+          } catch (IncorrectObjectTypeException notCommit) {
+            reject(cmd, "base must be a commit");
+            return;
+          } catch (MissingObjectException e) {
+            reject(cmd, "base not found");
+            return;
+          } catch (IOException e) {
+            logWarn(String.format(
+                "Project %s cannot read %s",
+                project.getName(), id.name()), e);
+            reject(cmd, "internal server error");
+            return;
+          }
+        }
+      } else if (newChangeForAllNotInTarget) {
+        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
+        if (branchTip == null) {
+          return; // readBranchTip already rejected cmd.
+        }
+        magicBranch.baseCommit = Collections.singletonList(branchTip);
         logDebug("Set baseCommit = {}", magicBranch.baseCommit.get(0).name());
-      } catch (IOException ex) {
-        logWarn(String.format("Project %s cannot read %s", project.getName(),
-            destBranch), ex);
-        reject(cmd, "internal server error");
-        return;
       }
+    } catch (IOException ex) {
+      logWarn(String.format("Error walking to %s in project %s",
+          destBranch, project.getName()), ex);
+      reject(cmd, "internal server error");
+      return;
     }
 
     // Validate that the new commits are connected with the target
@@ -1540,6 +1580,16 @@
     }
   }
 
+  private RevCommit readBranchTip(ReceiveCommand cmd, Branch.NameKey branch)
+      throws IOException {
+    Ref r = allRefs.get(branch.get());
+    if (r == null) {
+      reject(cmd, branch.get() + " not found");
+      return null;
+    }
+    return rp.getRevWalk().parseCommit(r.getObjectId());
+  }
+
   private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
     logDebug("Parsing replace command");
     if (cmd.getType() != ReceiveCommand.Type.CREATE) {
@@ -1604,29 +1654,10 @@
     GroupCollector groupCollector = GroupCollector.create(changeRefsById(), db, psUtil,
         notesFactory, project.getNameKey());
 
-    rp.getRevWalk().reset();
-    rp.getRevWalk().sort(RevSort.TOPO);
-    rp.getRevWalk().sort(RevSort.REVERSE, true);
     try {
-      RevCommit start = rp.getRevWalk().parseCommit(magicBranch.cmd.getNewId());
-      rp.getRevWalk().markStart(start);
-      if (magicBranch.baseCommit != null) {
-        logDebug("Marking {} base commits uninteresting",
-            magicBranch.baseCommit.size());
-        for (RevCommit c : magicBranch.baseCommit) {
-          rp.getRevWalk().markUninteresting(c);
-        }
-        Ref targetRef = allRefs.get(magicBranch.ctl.getRefName());
-        if (targetRef != null) {
-          logDebug("Marking target ref {} ({}) uninteresting",
-              magicBranch.ctl.getRefName(), targetRef.getObjectId().name());
-          rp.getRevWalk().markUninteresting(
-              rp.getRevWalk().parseCommit(targetRef.getObjectId()));
-        }
-      } else {
-        markHeadsAsUninteresting(
-            rp.getRevWalk(),
-            magicBranch.ctl != null ? magicBranch.ctl.getRefName() : null);
+      RevCommit start = setUpWalkForSelectingChanges();
+      if (start == null) {
+        return;
       }
 
       List<ChangeLookup> pending = new ArrayList<>();
@@ -1636,7 +1667,11 @@
       int total = 0;
       int alreadyTracked = 0;
       boolean rejectImplicitMerges = start.getParentCount() == 1
-          && projectCache.get(project.getNameKey()).isRejectImplicitMerges();
+          && projectCache.get(project.getNameKey()).isRejectImplicitMerges()
+          // Don't worry about implicit merges when creating changes for
+          // already-merged commits; they're already in history, so it's too
+          // late.
+          && !magicBranch.merged;
       Set<RevCommit> mergedParents;
       if (rejectImplicitMerges) {
         mergedParents = new HashSet<>();
@@ -1655,9 +1690,7 @@
         Collection<Ref> existingRefs = existing.get(c);
 
         if (rejectImplicitMerges) {
-          for (RevCommit p : c.getParents()) {
-            mergedParents.add(p);
-          }
+          Collections.addAll(mergedParents, c.getParents());
           mergedParents.remove(c);
         }
 
@@ -1750,14 +1783,10 @@
         List<ChangeData> changes = p.destChanges;
         if (changes.size() > 1) {
           logDebug("Multiple changes in project with Change-Id {}: {}",
-              p.changeKey, Lists.transform(
-                  changes,
-                  new Function<ChangeData, String>() {
-                    @Override
-                    public String apply(ChangeData in) {
-                      return in.getId().toString();
-                    }
-                  }));
+              p.changeKey,
+              changes.stream()
+                  .map(cd -> cd.getId().toString())
+                  .collect(joining()));
           // WTF, multiple changes in this project have the same key?
           // Since the commit is new, the user should recreate it with
           // a different Change-Id. In practice, we should never see
@@ -1801,6 +1830,18 @@
             return;
           }
 
+          // In case the change look up from the index failed,
+          // double check against the existing refs
+          if (foundInExistingRef(existing.get(p.commit))) {
+            if (pending.size() == 1) {
+              reject(magicBranch.cmd,
+                  "commit(s) already exists (as current patchset)");
+              newChanges = Collections.emptyList();
+              return;
+            }
+            itr.remove();
+            continue;
+          }
           newChangeIds.add(p.changeKey);
         }
         newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get()));
@@ -1854,8 +1895,62 @@
     }
   }
 
+  private boolean foundInExistingRef(Collection<Ref> existingRefs)
+      throws OrmException {
+    for (Ref ref : existingRefs) {
+      ChangeNotes notes = notesFactory.create(db, project.getNameKey(),
+          Change.Id.fromRef(ref.getName()));
+      Change change = notes.getChange();
+      if (change.getDest().equals(magicBranch.dest)) {
+        logDebug("Found change {} from existing refs.", change.getKey());
+        // reindex the change asynchronously
+        indexer.indexAsync(project.getNameKey(), change.getId());
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private RevCommit setUpWalkForSelectingChanges() throws IOException {
+    RevWalk rw = rp.getRevWalk();
+    RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
+
+    rw.reset();
+    rw.sort(RevSort.TOPO);
+    rw.sort(RevSort.REVERSE, true);
+    rp.getRevWalk().markStart(start);
+    if (magicBranch.baseCommit != null) {
+      markExplicitBasesUninteresting();
+    } else if (magicBranch.merged) {
+      logDebug(
+          "Marking parents of merged commit {} uninteresting", start.name());
+      for (RevCommit c : start.getParents()) {
+        rw.markUninteresting(c);
+      }
+    } else {
+      markHeadsAsUninteresting(
+          rw, magicBranch.ctl != null ? magicBranch.ctl.getRefName() : null);
+    }
+    return start;
+  }
+
+  private void markExplicitBasesUninteresting() throws IOException {
+    logDebug("Marking {} base commits uninteresting",
+        magicBranch.baseCommit.size());
+    for (RevCommit c : magicBranch.baseCommit) {
+      rp.getRevWalk().markUninteresting(c);
+    }
+    Ref targetRef = allRefs.get(magicBranch.ctl.getRefName());
+    if (targetRef != null) {
+      logDebug("Marking target ref {} ({}) uninteresting",
+          magicBranch.ctl.getRefName(), targetRef.getObjectId().name());
+      rp.getRevWalk().markUninteresting(
+          rp.getRevWalk().parseCommit(targetRef.getObjectId()));
+    }
+  }
+
   private void rejectImplicitMerges(Set<RevCommit> mergedParents)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+      throws IOException {
     if (!mergedParents.isEmpty()) {
       Ref targetRef = allRefs.get(magicBranch.ctl.getRefName());
       if (targetRef != null) {
@@ -1938,13 +2033,17 @@
     private void setChangeId(int id) {
       changeId = new Change.Id(id);
       ins = changeInserterFactory.create(changeId, commit, refName)
-          .setDraft(magicBranch.draft)
           .setTopic(magicBranch.topic)
           // Changes already validated in validateNewCommits.
           .setValidatePolicy(CommitValidators.Policy.NONE);
+
+      if (magicBranch.draft) {
+        ins.setDraft(magicBranch.draft);
+      } else if (magicBranch.merged) {
+        ins.setStatus(Change.Status.MERGED);
+      }
       cmd = new ReceiveCommand(ObjectId.zeroId(), commit,
           ins.getPatchSetId().toRefName());
-      ins.setUpdateRefCommand(cmd);
       if (rp.getPushCertificate() != null) {
         ins.setPushCertificate(rp.getPushCertificate().toTextWithSignature());
       }
@@ -1983,7 +2082,7 @@
             .setNotify(magicBranch.notify)
             .setRequestScopePropagator(requestScopePropagator)
             .setSendMail(true)
-            .setUpdateRef(true));
+            .setUpdateRef(false));
         if (!magicBranch.hashtags.isEmpty()) {
           bu.addOp(
               changeId,
@@ -2035,7 +2134,7 @@
     logDebug("Processing submit with tip change {} ({})",
         tipChange.getId(), magicBranch.cmd.getNewId());
     try (MergeOp op  = mergeOpProvider.get()) {
-      op.merge(db, tipChange, user, false, new SubmitInput());
+      op.merge(db, tipChange, user, false, new SubmitInput(), false);
     }
   }
 
@@ -2099,14 +2198,8 @@
     Collection<ChangeNotes> allNotes =
         notesFactory.create(
             db,
-            Collections2.transform(
-                replaceByChange.values(),
-                new Function<ReplaceRequest, Change.Id>() {
-                  @Override
-                  public Change.Id apply(ReplaceRequest in) {
-                    return in.ontoChange;
-                  }
-                }));
+            replaceByChange.values().stream()
+                .map(r -> r.ontoChange).collect(toList()));
     for (ChangeNotes notes : allNotes) {
       replaceByChange.get(notes.getChangeId()).notes = notes;
     }
@@ -2339,10 +2432,12 @@
       rw.parseBody(newCommit);
 
       RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
-      replaceOp = replaceOpFactory.create(requestScopePropagator,
-          projectControl, notes.getChange().getDest(), checkMergedInto,
-          priorPatchSet, priorCommit, psId, newCommit, info, groups,
-          magicBranch, rp.getPushCertificate());
+      replaceOp = replaceOpFactory
+          .create(projectControl, notes.getChange().getDest(), checkMergedInto,
+              priorPatchSet, priorCommit, psId, newCommit, info, groups,
+              magicBranch, rp.getPushCertificate())
+          .setRequestScopePropagator(requestScopePropagator)
+          .setUpdateRef(false);
       bu.addOp(notes.getChangeId(), replaceOp);
       if (progress != null) {
         bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
@@ -2556,12 +2651,20 @@
     rw.parseBody(c);
     CommitReceivedEvent receiveEvent =
         new CommitReceivedEvent(cmd, project, ctl.getRefName(), c, user);
-    CommitValidators commitValidators =
-        commitValidatorsFactory.create(ctl, sshInfo, repo);
+
+    CommitValidators.Policy policy;
+    if (magicBranch != null
+        && cmd.getRefName().equals(magicBranch.cmd.getRefName())
+        && magicBranch.merged) {
+      policy = CommitValidators.Policy.MERGED;
+    } else {
+      policy = CommitValidators.Policy.RECEIVE_COMMITS;
+    }
 
     try {
-      messages.addAll(commitValidators.validateForReceiveCommits(
-          receiveEvent, rejectCommits));
+      messages.addAll(
+          commitValidatorsFactory.create(policy, ctl, sshInfo, repo)
+              .validate(receiveEvent));
     } catch (CommitValidationException e) {
       logDebug("Commit validation failed on {}", c.name());
       messages.addAll(e.getMessages());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java
index 51c2a80..5871299 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java
@@ -16,11 +16,15 @@
 
 import static org.eclipse.jgit.lib.RefDatabase.ALL;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.MagicBranch;
@@ -46,6 +50,13 @@
   private static final Logger log = LoggerFactory
       .getLogger(ReceiveCommitsAdvertiseRefsHook.class);
 
+  @VisibleForTesting
+  @AutoValue
+  public abstract static class Result {
+    public abstract Map<String, Ref> allRefs();
+    public abstract Set<ObjectId> additionalHaves();
+  }
+
   private final Provider<InternalChangeQuery> queryProvider;
   private final Project.NameKey projectName;
 
@@ -77,28 +88,53 @@
         throw ex;
       }
     }
+    Result r = advertiseRefs(oldRefs);
+    rp.setAdvertisedRefs(r.allRefs(), r.additionalHaves());
+  }
+
+  @VisibleForTesting
+  public Result advertiseRefs(Map<String, Ref> oldRefs) {
     Map<String, Ref> r = Maps.newHashMapWithExpectedSize(oldRefs.size());
+    Set<ObjectId> allPatchSets = Sets.newHashSetWithExpectedSize(oldRefs.size());
     for (Map.Entry<String, Ref> e : oldRefs.entrySet()) {
       String name = e.getKey();
       if (!skip(name)) {
         r.put(name, e.getValue());
       }
+      if (name.startsWith(RefNames.REFS_CHANGES)) {
+        allPatchSets.add(e.getValue().getObjectId());
+      }
     }
-    rp.setAdvertisedRefs(r, advertiseOpenChanges());
+    return new AutoValue_ReceiveCommitsAdvertiseRefsHook_Result(
+        r, advertiseOpenChanges(allPatchSets));
   }
 
-  private Set<ObjectId> advertiseOpenChanges() {
+  private static final ImmutableSet<String> OPEN_CHANGES_FIELDS =
+      ImmutableSet.of(
+          // Required for ChangeIsVisibleToPrdicate.
+          ChangeField.CHANGE.getName(),
+          // Required during advertiseOpenChanges.
+          ChangeField.PATCH_SET.getName());
+
+  private Set<ObjectId> advertiseOpenChanges(Set<ObjectId> allPatchSets) {
     // Advertise some recent open changes, in case a commit is based on one.
     int limit = 32;
     try {
       Set<ObjectId> r = Sets.newHashSetWithExpectedSize(limit);
       for (ChangeData cd : queryProvider.get()
+          .setRequestedFields(OPEN_CHANGES_FIELDS)
           .enforceVisibility(true)
           .setLimit(limit)
           .byProjectOpen(projectName)) {
         PatchSet ps = cd.currentPatchSet();
         if (ps != null) {
-          r.add(ObjectId.fromString(ps.getRevision().get()));
+          ObjectId id = ObjectId.fromString(ps.getRevision().get());
+          // Ensure we actually observed a patch set ref pointing to this
+          // object, in case the database is out of sync with the repo and the
+          // object doesn't actually exist.
+          if (allPatchSets.contains(id)) {
+            r.add(id);
+          }
         }
       }
       return r;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java
index 562db08..96593ac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.common.base.Optional;
-
 import org.eclipse.jgit.lib.ObjectId;
 
 import java.io.IOException;
+import java.util.Optional;
 
 /**
  * Simple short-lived cache of individual refs read from a repo.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
index 7754813..a60b86f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeKindCache;
@@ -46,7 +45,7 @@
 import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -65,11 +64,11 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushCertificate;
+import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -79,7 +78,6 @@
 public class ReplaceOp extends BatchUpdate.Op {
   public interface Factory {
     ReplaceOp create(
-        RequestScopePropagator requestScopePropagator,
         ProjectControl projectControl,
         Branch.NameKey dest,
         boolean checkMergedInto,
@@ -113,7 +111,6 @@
   private final PatchSetUtil psUtil;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
 
-  private final RequestScopePropagator requestScopePropagator;
   private final ProjectControl projectControl;
   private final Branch.NameKey dest;
   private final boolean checkMergedInto;
@@ -134,6 +131,8 @@
   private ChangeMessage msg;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
+  private RequestScopePropagator requestScopePropagator;
+  private boolean updateRef;
 
   @AssistedInject
   ReplaceOp(AccountResolver accountResolver,
@@ -150,7 +149,6 @@
       PatchSetUtil psUtil,
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
-      @Assisted RequestScopePropagator requestScopePropagator,
       @Assisted ProjectControl projectControl,
       @Assisted Branch.NameKey dest,
       @Assisted boolean checkMergedInto,
@@ -177,7 +175,6 @@
     this.replacePatchSetFactory = replacePatchSetFactory;
     this.sendEmailExecutor = sendEmailExecutor;
 
-    this.requestScopePropagator = requestScopePropagator;
     this.projectControl = projectControl;
     this.dest = dest;
     this.checkMergedInto = checkMergedInto;
@@ -189,11 +186,13 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
+    this.updateRef = true;
   }
 
   @Override
   public void updateRepo(RepoContext ctx) throws Exception {
-    changeKind = changeKindCache.getChangeKind(projectControl.getProjectState(),
+    changeKind = changeKindCache.getChangeKind(
+        projectControl.getProject().getNameKey(),
         ctx.getRepository(), priorCommit, commit);
 
     if (checkMergedInto) {
@@ -203,6 +202,12 @@
             requestScopePropagator, patchSetId, mergedInto.getName());
       }
     }
+
+    if (updateRef) {
+      ctx.addRefUpdate(
+          new ReceiveCommand(ObjectId.zeroId(), commit,
+              patchSetId.toRefName()));
+    }
   }
 
   @Override
@@ -255,13 +260,15 @@
     ChangeData cd = changeDataFactory.create(ctx.getDb(), ctx.getControl());
     MailRecipients oldRecipients =
         getRecipientsFromReviewers(cd.reviewers());
-    approvalCopier.copy(ctx.getDb(), ctx.getControl(), newPatchSet);
+    Iterable<PatchSetApproval> newApprovals =
+        approvalsUtil.addApprovalsForNewPatchSet(ctx.getDb(), update,
+            projectControl.getLabelTypes(), newPatchSet, ctx.getControl(),
+            approvals);
+    approvalCopier.copy(ctx.getDb(), ctx.getControl(), newPatchSet,
+        newApprovals);
     approvalsUtil.addReviewers(ctx.getDb(), update,
         projectControl.getLabelTypes(), change, newPatchSet, info,
         recipients.getReviewers(), oldRecipients.getAll());
-    approvalsUtil.addApprovals(ctx.getDb(), update,
-        projectControl.getLabelTypes(), newPatchSet, ctx.getControl(),
-        approvals);
     recipients.add(oldRecipients);
 
     String approvalMessage = ApprovalsUtil.renderMessageWithApprovals(
@@ -276,15 +283,12 @@
     if (!Strings.isNullOrEmpty(reviewMessage)) {
       message.append("\n").append(reviewMessage);
     }
-    msg = new ChangeMessage(
-        new ChangeMessage.Key(change.getId(),
-            ChangeUtil.messageUUID(ctx.getDb())),
-        ctx.getAccountId(), ctx.getWhen(), patchSetId);
-    msg.setMessage(message.toString());
+    msg = ChangeMessagesUtil.newMessage(ctx.getDb(), patchSetId, ctx.getUser(),
+        ctx.getWhen(), message.toString(), ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
     cmUtil.addChangeMessage(ctx.getDb(), update, msg);
 
     if (mergedByPushOp == null) {
-      resetChange(ctx, msg);
+      resetChange(ctx);
     } else {
       mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet))
           .updateChange(ctx);
@@ -328,16 +332,8 @@
     return current;
   }
 
-  private void resetChange(ChangeContext ctx, ChangeMessage msg)
-      throws OrmException {
+  private void resetChange(ChangeContext ctx) {
     Change change = ctx.getChange();
-    if (change.getStatus().isClosed()) {
-      ctx.getDb().patchSets().delete(Collections.singleton(newPatchSet));
-      ctx.getDb().changeMessages().delete(Collections.singleton(msg));
-      rejectMessage = CHANGE_IS_CLOSED;
-      return;
-    }
-
     if (!change.currentPatchSetId().equals(priorPatchSetId)) {
       return;
     }
@@ -367,8 +363,10 @@
     // BatchUpdate's perspective there is no ref update. Thus we have to fire it
     // manually.
     final Account account = ctx.getAccount();
-    gitRefUpdated.fire(ctx.getProject(), newPatchSet.getRefName(),
-        ObjectId.zeroId(), commit, account);
+    if (!updateRef) {
+      gitRefUpdated.fire(ctx.getProject(), newPatchSet.getRefName(),
+          ObjectId.zeroId(), commit, account);
+    }
 
     if (changeKind != ChangeKind.TRIVIAL_REBASE) {
       Runnable sender = new Runnable() {
@@ -455,10 +453,25 @@
     return newPatchSet;
   }
 
+  public Change getChange() {
+    return change;
+  }
+
   public String getRejectMessage() {
     return rejectMessage;
   }
 
+  public ReplaceOp setUpdateRef(boolean updateRef) {
+    this.updateRef = updateRef;
+    return this;
+  }
+
+  public ReplaceOp setRequestScopePropagator(
+      RequestScopePropagator requestScopePropagator) {
+    this.requestScopePropagator = requestScopePropagator;
+    return this;
+  }
+
   private Ref findMergedInto(Context ctx, String first, RevCommit commit) {
     try {
       RefDatabase refDatabase = ctx.getRepository().getRefDatabase();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
index 1dfa51e..77f697a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.common.base.Optional;
-
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
@@ -24,6 +22,7 @@
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
 
 /** {@link RefCache} backed directly by a repository. */
 public class RepoRefCache implements RefCache {
@@ -42,9 +41,7 @@
       return id;
     }
     Ref ref = refdb.exactRef(refName);
-    id = ref != null
-        ? Optional.of(ref.getObjectId())
-        : Optional.<ObjectId>absent();
+    id = Optional.ofNullable(ref).map(Ref::getObjectId);
     ids.put(refName, id);
     return id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
index 01d73ec..9491611 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
@@ -103,12 +103,21 @@
   private final ProjectState.Factory projectStateFactory;
   private final VerboseSuperprojectUpdate verboseSuperProject;
   private final boolean enableSuperProjectSubscriptions;
-  private final Multimap<Branch.NameKey, SubmoduleSubscription> targets;
-  private final Set<Branch.NameKey> updatedBranches;
   private final MergeOpRepoManager orm;
-  private final Map<Branch.NameKey, CodeReviewCommit> branchTips;
   private final Map<Branch.NameKey, GitModules> branchGitModules;
+
+  // always update-to-current branch tips during submit process
+  private final Map<Branch.NameKey, CodeReviewCommit> branchTips;
+  // branches for all the submitting changes
+  private final Set<Branch.NameKey> updatedBranches;
+  // branches which in either a submodule or a superproject
+  private final Set<Branch.NameKey> affectedBranches;
+  // sorted version of affectedBranches
   private final ImmutableSet<Branch.NameKey> sortedBranches;
+  // map of superproject branch and its submodule subscriptions
+  private final Multimap<Branch.NameKey, SubmoduleSubscription> targets;
+  // map of superproject and its branches which has submodule subscriptions
+  private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
 
   @AssistedInject
   public SubmoduleOp(
@@ -131,8 +140,10 @@
     this.orm = orm;
     this.updatedBranches = updatedBranches;
     this.targets = HashMultimap.create();
+    this.affectedBranches = new HashSet<>();
     this.branchTips = new HashMap<>();
     this.branchGitModules = new HashMap<>();
+    this.branchesByProject = HashMultimap.create();
     this.sortedBranches = calculateSubscriptionMap();
   }
 
@@ -154,8 +165,11 @@
           allVisited);
     }
 
-    // Since the searchForSuperprojects will add the superprojects before one
-    // submodule in sortedBranches, need reverse the order of it
+    // Since the searchForSuperprojects will add all branches (related or
+    // unrelated) and ensure the superproject's branches get added first before
+    // a submodule branch. Need remove all unrelated branches and reverse
+    // the order.
+    allVisited.retainAll(affectedBranches);
     reverse(allVisited);
     return ImmutableSet.copyOf(allVisited);
   }
@@ -181,9 +195,12 @@
       Collection<SubmoduleSubscription> subscriptions =
           superProjectSubscriptionsForSubmoduleBranch(current);
       for (SubmoduleSubscription sub : subscriptions) {
-        Branch.NameKey superProject = sub.getSuperProject();
-        searchForSuperprojects(superProject, currentVisited, allVisited);
-        targets.put(superProject, sub);
+        Branch.NameKey superBranch = sub.getSuperProject();
+        searchForSuperprojects(superBranch, currentVisited, allVisited);
+        targets.put(superBranch, sub);
+        branchesByProject.put(superBranch.getParentKey(), superBranch);
+        affectedBranches.add(superBranch);
+        affectedBranches.add(sub.getSubmodule());
       }
     } catch (IOException e) {
       throw new SubmoduleException("Cannot find superprojects for " + current,
@@ -251,7 +268,7 @@
       }
       OpenRepo or;
       try {
-        or = orm.openRepo(s.getProject(), false);
+        or = orm.openRepo(s.getProject());
       } catch (NoSuchProjectException e) {
         // A project listed a non existent project to be allowed
         // to subscribe to it. Allow this for now, i.e. no exception is
@@ -290,7 +307,7 @@
       for (Branch.NameKey targetBranch : branches) {
         Project.NameKey targetProject = targetBranch.getParentKey();
         try {
-          OpenRepo or = orm.openRepo(targetProject, false);
+          OpenRepo or = orm.openRepo(targetProject);
           ObjectId id = or.repo.resolve(targetBranch.get());
           if (id == null) {
             logDebug("The branch " + targetBranch + " doesn't exist.");
@@ -319,22 +336,21 @@
       return;
     }
 
-    SetMultimap<Project.NameKey, Branch.NameKey> dst = branchesByProject();
     LinkedHashSet<Project.NameKey> superProjects = new LinkedHashSet<>();
     try {
       for (Project.NameKey project : projects) {
         // only need superprojects
-        if (dst.containsKey(project)) {
+        if (branchesByProject.containsKey(project)) {
           superProjects.add(project);
           // get a new BatchUpdate for the super project
-          OpenRepo or = orm.openRepo(project, false);
-          for (Branch.NameKey branch : dst.get(project)) {
+          OpenRepo or = orm.openRepo(project);
+          for (Branch.NameKey branch : branchesByProject.get(project)) {
             addOp(or.getUpdate(), branch);
           }
         }
       }
       BatchUpdate.execute(orm.batchUpdates(superProjects), Listener.NONE,
-          orm.getSubmissionId());
+          orm.getSubmissionId(), false);
     } catch (RestApiException | UpdateException | IOException |
         NoSuchProjectException e) {
       throw new SubmoduleException("Cannot update gitlinks", e);
@@ -348,18 +364,23 @@
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
-      or = orm.openRepo(subscriber.getParentKey(), false);
+      or = orm.openRepo(subscriber.getParentKey());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access superproject", e);
     }
 
     CodeReviewCommit currentCommit;
-    Ref r = or.repo.exactRef(subscriber.get());
-    if (r == null) {
-      throw new SubmoduleException(
-          "The branch was probably deleted from the subscriber repository");
+    if (branchTips.containsKey(subscriber)) {
+      currentCommit = branchTips.get(subscriber);
+    } else {
+      Ref r = or.repo.exactRef(subscriber.get());
+      if (r == null) {
+        throw new SubmoduleException(
+            "The branch was probably deleted from the subscriber repository");
+      }
+      currentCommit = or.rw.parseCommit(r.getObjectId());
+      addBranchTip(subscriber, currentCommit);
     }
-    currentCommit = or.rw.parseCommit(r.getObjectId());
 
     StringBuilder msgbuf = new StringBuilder("");
     PersonIdent author = null;
@@ -404,7 +425,7 @@
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
-      or = orm.openRepo(subscriber.getParentKey(), false);
+      or = orm.openRepo(subscriber.getParentKey());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access superproject", e);
     }
@@ -436,7 +457,9 @@
     commit.setAuthor(currentCommit.getAuthorIdent());
     commit.setCommitter(myIdent);
     ObjectId id = or.ins.insert(commit);
-    return or.rw.parseCommit(id);
+    CodeReviewCommit newCommit = or.rw.parseCommit(id);
+    newCommit.copyFrom(currentCommit);
+    return newCommit;
   }
 
   private RevCommit updateSubmodule(DirCache dc, DirCacheEditor ed,
@@ -444,7 +467,7 @@
       throws SubmoduleException, IOException {
     OpenRepo subOr;
     try {
-      subOr = orm.openRepo(s.getSubmodule().getParentKey(), false);
+      subOr = orm.openRepo(s.getSubmodule().getParentKey());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access submodule", e);
     }
@@ -461,7 +484,7 @@
       oldCommit = subOr.rw.parseCommit(dce.getObjectId());
     }
 
-    final RevCommit newCommit;
+    final CodeReviewCommit newCommit;
     if (branchTips.containsKey(s.getSubmodule())) {
       newCommit = branchTips.get(s.getSubmodule());
     } else {
@@ -471,6 +494,7 @@
         return null;
       }
       newCommit = subOr.rw.parseCommit(ref.getObjectId());
+      addBranchTip(s.getSubmodule(), newCommit);
     }
 
     if (Objects.equals(newCommit, oldCommit)) {
@@ -532,41 +556,57 @@
     return dc;
   }
 
-  public SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject() {
-    SetMultimap<Project.NameKey, Branch.NameKey> ret = HashMultimap.create();
-    for (Branch.NameKey branch : targets.keySet()) {
-      ret.put(branch.getParentKey(), branch);
-    }
-
-    return ret;
-  }
-
   public ImmutableSet<Project.NameKey> getProjectsInOrder()
       throws SubmoduleException {
-    if (sortedBranches == null) {
-      return null;
-    }
-
     LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
-    Project.NameKey prev = null;
-    for (Branch.NameKey branch : sortedBranches) {
-      Project.NameKey project = branch.getParentKey();
-      if (!project.equals(prev)) {
-        if (projects.contains(project)) {
-          throw new SubmoduleException(
-              "Project level circular subscriptions detected:  " +
-                  printCircularPath(projects, project));
-        }
-        projects.add(project);
-      }
-      prev = project;
+    for (Project.NameKey project : branchesByProject.keySet()) {
+      addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
     }
 
+    for (Branch.NameKey branch : updatedBranches) {
+      projects.add(branch.getParentKey());
+    }
     return ImmutableSet.copyOf(projects);
   }
 
+  private void addAllSubmoduleProjects(Project.NameKey project,
+      LinkedHashSet<Project.NameKey> current,
+      LinkedHashSet<Project.NameKey> projects)
+      throws SubmoduleException {
+    if (current.contains(project)) {
+      throw new SubmoduleException(
+          "Project level circular subscriptions detected:  " +
+              printCircularPath(current, project));
+    }
+
+    if (projects.contains(project)) {
+      return;
+    }
+
+    current.add(project);
+    Set<Project.NameKey> subprojects = new HashSet<>();
+    for (Branch.NameKey branch : branchesByProject.get(project)) {
+      Collection<SubmoduleSubscription> subscriptions = targets.get(branch);
+      for (SubmoduleSubscription s : subscriptions) {
+        subprojects.add(s.getSubmodule().getParentKey());
+      }
+    }
+
+    for (Project.NameKey p : subprojects) {
+      addAllSubmoduleProjects(p, current, projects);
+    }
+
+    current.remove(project);
+    projects.add(project);
+  }
+
   public ImmutableSet<Branch.NameKey> getBranchesInOrder() {
-    return sortedBranches;
+    LinkedHashSet<Branch.NameKey> branches = new LinkedHashSet<>();
+    if (sortedBranches != null) {
+      branches.addAll(sortedBranches);
+    }
+    branches.addAll(updatedBranches);
+    return ImmutableSet.copyOf(branches);
   }
 
   public boolean hasSubscription(Branch.NameKey branch) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSetHolder.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSetHolder.java
index 5260aab..ad650c3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSetHolder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSetHolder.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.common.base.Predicate;
-import com.google.common.collect.FluentIterable;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.reviewdb.client.Project;
 
 import org.eclipse.jgit.lib.Ref;
@@ -45,12 +45,7 @@
   }
 
   TagMatcher matcher(TagCache cache, Repository db, Collection<Ref> include) {
-    include = FluentIterable.from(include).filter(new Predicate<Ref>() {
-      @Override
-      public boolean apply(Ref ref) {
-        return !TagSet.skip(ref);
-      }
-    }).toList();
+    include = include.stream().filter(r -> !TagSet.skip(r)).collect(toList());
 
     TagSet tags = this.tags;
     if (tags == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
index 4a5e94d..c0d96c9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
@@ -48,14 +48,14 @@
 
   @Override
   public List<SubmitStrategyOp> buildOps(
-      Collection<CodeReviewCommit> toMerge) {
+      Collection<CodeReviewCommit> toMerge) throws IntegrationException {
     List<CodeReviewCommit> sorted = CodeReviewCommit.ORDER.sortedCopy(toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     boolean first = true;
     while (!sorted.isEmpty()) {
       CodeReviewCommit n = sorted.remove(0);
       if (first && args.mergeTip.getInitialTip() == null) {
-        ops.add(new CherryPickUnbornRootOp(n));
+        ops.add(new FastForwardOp(args, n));
       } else if (n.getParentCount() == 0) {
         ops.add(new CherryPickRootOp(n));
       } else if (n.getParentCount() == 1) {
@@ -68,21 +68,6 @@
     return ops;
   }
 
-  private class CherryPickUnbornRootOp extends SubmitStrategyOp {
-    private CherryPickUnbornRootOp(CodeReviewCommit toMerge) {
-      super(CherryPick.this.args, toMerge);
-    }
-
-    @Override
-    protected void updateRepoImpl(RepoContext ctx) throws IntegrationException {
-      // The branch is unborn. Take fast-forward resolution to create the
-      // branch.
-      CodeReviewCommit newCommit = amendGitlink(toMerge);
-      args.mergeTip.moveTipTo(newCommit, toMerge);
-      newCommit.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
-    }
-  }
-
   private class CherryPickRootOp extends SubmitStrategyOp {
     private CherryPickRootOp(CodeReviewCommit toMerge) {
       super(CherryPick.this.args, toMerge);
@@ -122,7 +107,7 @@
       try {
         newCommit = args.mergeUtil.createCherryPickFromCommit(
             args.repo, args.inserter, args.mergeTip.getCurrentTip(), toMerge,
-            committer, cherryPickCmtMsg, args.rw);
+            committer, cherryPickCmtMsg, args.rw, 0);
       } catch (MergeConflictException mce) {
         // Keep going in the case of a single merge failure; the goal is to
         // cherry-pick as many commits as possible.
@@ -191,8 +176,9 @@
       // different first parent. So instead behave as though MERGE_IF_NECESSARY
       // was configured.
       MergeTip mergeTip = args.mergeTip;
-      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)) {
-        mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
+      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge) &&
+          !args.submoduleOp.hasSubscription(args.destBranch)) {
+        mergeTip.moveTipTo(toMerge, toMerge);
       } else {
         PersonIdent myIdent = new PersonIdent(args.serverIdent, ctx.getWhen());
         CodeReviewCommit result = args.mergeUtil.mergeOneCommit(myIdent,
@@ -200,9 +186,9 @@
             mergeTip.getCurrentTip(), toMerge);
         result = amendGitlink(result);
         mergeTip.moveTipTo(result, toMerge);
+        args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
+            mergeTip.getCurrentTip(), args.alreadyAccepted);
       }
-      args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-          mergeTip.getCurrentTip(), args.alreadyAccepted);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
index 0e69128..bb58540 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
@@ -25,6 +25,6 @@
 
   @Override
   protected void updateRepoImpl(RepoContext ctx) throws IntegrationException {
-    args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
+    args.mergeTip.moveTipTo(toMerge, toMerge);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
index 0e2cbd7..5b2e213 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
@@ -32,11 +32,15 @@
     List<CodeReviewCommit> sorted =
         args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
-    CodeReviewCommit firstFastForward = args.mergeUtil.getFirstFastForward(
+
+    if (args.mergeTip.getInitialTip() == null || !args.submoduleOp
+        .hasSubscription(args.destBranch)) {
+      CodeReviewCommit firstFastForward = args.mergeUtil.getFirstFastForward(
           args.mergeTip.getInitialTip(), args.rw, sorted);
-    if (firstFastForward != null &&
-        !firstFastForward.equals(args.mergeTip.getInitialTip())) {
-      ops.add(new FastForwardOp(args, firstFastForward));
+      if (firstFastForward != null &&
+          !firstFastForward.equals(args.mergeTip.getInitialTip())) {
+        ops.add(new FastForwardOp(args, firstFastForward));
+      }
     }
 
     // For every other commit do a pair-wise merge.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseAlways.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseAlways.java
index 9bcabc3..26bb4c1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseAlways.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.git.strategy;
 
-import com.google.gerrit.reviewdb.client.Account;
+public class RebaseAlways extends RebaseSubmitStrategy {
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
+  RebaseAlways(SubmitStrategy.Arguments args) {
+    super(args, true);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
index f183772d..104074a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
@@ -14,221 +14,9 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.RebaseChangeOp;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeTip;
-import com.google.gerrit.server.git.RebaseSorter;
-import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-
-public class RebaseIfNecessary extends SubmitStrategy {
+public class RebaseIfNecessary extends RebaseSubmitStrategy {
 
   RebaseIfNecessary(SubmitStrategy.Arguments args) {
-    super(args);
-  }
-
-  @Override
-  public List<SubmitStrategyOp> buildOps(
-      Collection<CodeReviewCommit> toMerge) throws IntegrationException {
-    List<CodeReviewCommit> sorted = sort(toMerge);
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
-    boolean first = true;
-
-    for (CodeReviewCommit c : sorted) {
-      if (c.getParentCount() > 1) {
-        // Since there is a merge commit, sort and prune again using
-        // MERGE_IF_NECESSARY semantics to avoid creating duplicate
-        // commits.
-        //
-        sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
-        break;
-      }
-    }
-
-    while (!sorted.isEmpty()) {
-      CodeReviewCommit n = sorted.remove(0);
-      if (first && args.mergeTip.getInitialTip() == null) {
-        ops.add(new RebaseUnbornRootOp(n));
-      } else if (n.getParentCount() == 0) {
-        ops.add(new RebaseRootOp(n));
-      } else if (n.getParentCount() == 1) {
-        ops.add(new RebaseOneOp(n));
-      } else {
-        ops.add(new RebaseMultipleParentsOp(n));
-      }
-      first = false;
-    }
-    return ops;
-  }
-
-  private class RebaseUnbornRootOp extends SubmitStrategyOp {
-    private RebaseUnbornRootOp(CodeReviewCommit toMerge) {
-      super(RebaseIfNecessary.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx) throws IntegrationException {
-      // The branch is unborn. Take fast-forward resolution to create the
-      // branch.
-      toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
-      CodeReviewCommit newCommit = amendGitlink(toMerge);
-      args.mergeTip.moveTipTo(newCommit, toMerge);
-      acceptMergeTip(args.mergeTip);
-    }
-  }
-
-  private class RebaseRootOp extends SubmitStrategyOp {
-    private RebaseRootOp(CodeReviewCommit toMerge) {
-      super(RebaseIfNecessary.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx) {
-      // Refuse to merge a root commit into an existing branch, we cannot obtain
-      // a delta for the cherry-pick to apply.
-      toMerge.setStatusCode(CommitMergeStatus.CANNOT_REBASE_ROOT);
-    }
-  }
-
-  private class RebaseOneOp extends SubmitStrategyOp {
-    private RebaseChangeOp rebaseOp;
-    private CodeReviewCommit newCommit;
-
-    private RebaseOneOp(CodeReviewCommit toMerge) {
-      super(RebaseIfNecessary.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, InvalidChangeOperationException,
-        RestApiException, IOException, OrmException {
-      // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
-      // When hoisting BatchUpdate into MergeOp, we will need to teach
-      // BatchUpdate how to produce CodeReviewRevWalks.
-      if (args.mergeUtil.canFastForward(args.mergeSorter,
-          args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
-        args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
-        acceptMergeTip(args.mergeTip);
-        return;
-      }
-
-      // Stale read of patch set is ok; see comments in RebaseChangeOp.
-      PatchSet origPs = args.psUtil.get(
-          ctx.getDb(), toMerge.getControl().getNotes(), toMerge.getPatchsetId());
-      rebaseOp = args.rebaseFactory.create(
-            toMerge.getControl(), origPs, args.mergeTip.getCurrentTip().name())
-          .setFireRevisionCreated(false)
-          // Bypass approval copier since SubmitStrategyOp copy all approvals
-          // later anyway.
-          .setCopyApprovals(false)
-          .setValidatePolicy(CommitValidators.Policy.NONE);
-      try {
-        rebaseOp.updateRepo(ctx);
-      } catch (MergeConflictException | NoSuchChangeException e) {
-        toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
-        throw new IntegrationException(
-            "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
-      }
-      newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
-      newCommit = amendGitlink(newCommit);
-      newCommit.copyFrom(toMerge);
-      newCommit.setStatusCode(CommitMergeStatus.CLEAN_REBASE);
-      newCommit.setPatchsetId(rebaseOp.getPatchSetId());
-      args.mergeTip.moveTipTo(newCommit, newCommit);
-      args.commits.put(args.mergeTip.getCurrentTip());
-      acceptMergeTip(args.mergeTip);
-    }
-
-    @Override
-    public PatchSet updateChangeImpl(ChangeContext ctx)
-        throws NoSuchChangeException, ResourceConflictException,
-        OrmException, IOException  {
-      if (rebaseOp == null) {
-        // Took the fast-forward option, nothing to do.
-        return null;
-      }
-
-      rebaseOp.updateChange(ctx);
-      ctx.getChange().setCurrentPatchSet(
-          args.patchSetInfoFactory.get(
-              args.rw, newCommit, rebaseOp.getPatchSetId()));
-      newCommit.setControl(ctx.getControl());
-      return rebaseOp.getPatchSet();
-    }
-
-    @Override
-    public void postUpdateImpl(Context ctx) throws OrmException {
-      if (rebaseOp != null) {
-        rebaseOp.postUpdate(ctx);
-      }
-    }
-  }
-
-  private class RebaseMultipleParentsOp extends SubmitStrategyOp {
-    private RebaseMultipleParentsOp(CodeReviewCommit toMerge) {
-      super(RebaseIfNecessary.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, IOException {
-      // There are multiple parents, so this is a merge commit. We don't want
-      // to rebase the merge as clients can't easily rebase their history with
-      // that merge present and replaced by an equivalent merge with a different
-      // first parent. So instead behave as though MERGE_IF_NECESSARY was
-      // configured.
-      MergeTip mergeTip = args.mergeTip;
-      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)) {
-        mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
-        acceptMergeTip(mergeTip);
-      } else {
-        CodeReviewCommit newTip = args.mergeUtil.mergeOneCommit(
-            args.serverIdent, args.serverIdent, args.repo, args.rw,
-            args.inserter, args.destBranch, mergeTip.getCurrentTip(), toMerge);
-        mergeTip.moveTipTo(amendGitlink(newTip), toMerge);
-      }
-      args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-          mergeTip.getCurrentTip(), args.alreadyAccepted);
-      acceptMergeTip(mergeTip);
-    }
-  }
-
-  private void acceptMergeTip(MergeTip mergeTip) {
-    args.alreadyAccepted.add(mergeTip.getCurrentTip());
-  }
-
-  private List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort)
-      throws IntegrationException {
-    try {
-      return new RebaseSorter(
-          args.rw, args.alreadyAccepted, args.canMergeFlag).sort(toSort);
-    } catch (IOException e) {
-      throw new IntegrationException("Commit sorting failed", e);
-    }
-  }
-
-  static boolean dryRun(SubmitDryRun.Arguments args,
-      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
-      throws IntegrationException {
-    // Test for merge instead of cherry pick to avoid false negatives
-    // on commit chains.
-    return !args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)
-        && args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip,
-             toMerge);
+    super(args, false);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
new file mode 100644
index 0000000..925d515
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
@@ -0,0 +1,288 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.git.strategy.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeIdenticalTreeException;
+import com.google.gerrit.server.git.MergeTip;
+import com.google.gerrit.server.git.RebaseSorter;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * This strategy covers RebaseAlways and RebaseIfNecessary ones.
+ */
+public class RebaseSubmitStrategy extends SubmitStrategy {
+  private final boolean rebaseAlways;
+
+  RebaseSubmitStrategy(SubmitStrategy.Arguments args, boolean rebaseAlways) {
+    super(args);
+    this.rebaseAlways = rebaseAlways;
+  }
+
+  @Override
+  public List<SubmitStrategyOp> buildOps(
+      Collection<CodeReviewCommit> toMerge) throws IntegrationException {
+    List<CodeReviewCommit> sorted = sort(toMerge);
+    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    boolean first = true;
+
+    for (CodeReviewCommit c : sorted) {
+      if (c.getParentCount() > 1) {
+        // Since there is a merge commit, sort and prune again using
+        // MERGE_IF_NECESSARY semantics to avoid creating duplicate
+        // commits.
+        //
+        sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
+        break;
+      }
+    }
+
+    while (!sorted.isEmpty()) {
+      CodeReviewCommit n = sorted.remove(0);
+      if (first && args.mergeTip.getInitialTip() == null) {
+        // TODO(tandrii): Cherry-Pick strategy does this too, but it's wrong
+        // and can be fixed.
+        ops.add(new FastForwardOp(args, n));
+      } else if (n.getParentCount() == 0) {
+        ops.add(new RebaseRootOp(n));
+      } else if (n.getParentCount() == 1) {
+        ops.add(new RebaseOneOp(n));
+      } else {
+        ops.add(new RebaseMultipleParentsOp(n));
+      }
+      first = false;
+    }
+    return ops;
+  }
+
+  private class RebaseRootOp extends SubmitStrategyOp {
+    private RebaseRootOp(CodeReviewCommit toMerge) {
+      super(RebaseSubmitStrategy.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx) {
+      // Refuse to merge a root commit into an existing branch, we cannot obtain
+      // a delta for the cherry-pick to apply.
+      toMerge.setStatusCode(CommitMergeStatus.CANNOT_REBASE_ROOT);
+    }
+  }
+
+  private class RebaseOneOp extends SubmitStrategyOp {
+    private RebaseChangeOp rebaseOp;
+    private CodeReviewCommit newCommit;
+    private PatchSet.Id newPatchSetId;
+
+    private RebaseOneOp(CodeReviewCommit toMerge) {
+      super(RebaseSubmitStrategy.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx)
+        throws IntegrationException, InvalidChangeOperationException,
+        RestApiException, IOException, OrmException {
+      // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
+      // When hoisting BatchUpdate into MergeOp, we will need to teach
+      // BatchUpdate how to produce CodeReviewRevWalks.
+      if (args.mergeUtil
+          .canFastForward(args.mergeSorter, args.mergeTip.getCurrentTip(),
+              args.rw, toMerge)) {
+        if (!rebaseAlways){
+          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.nextPatchSetId(
+            args.repo, toMerge.change().currentPatchSetId());
+        // TODO(tandrii): add extension point to customize this commit message.
+        String cherryPickCmtMsg =
+            args.mergeUtil.createCherryPickCommitMessage(toMerge);
+
+        PersonIdent committer = args.caller.newCommitterIdent(ctx.getWhen(),
+            args.serverIdent.getTimeZone());
+        try {
+          newCommit = args.mergeUtil.createCherryPickFromCommit(args.repo,
+              args.inserter, args.mergeTip.getCurrentTip(), toMerge, committer,
+              cherryPickCmtMsg, args.rw, 0);
+        } 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");
+        } catch (MergeIdenticalTreeException mie) {
+          toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
+          return;
+        }
+        ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), newCommit,
+            newPatchSetId.toRefName()));
+      } else {
+        // Stale read of patch set is ok; see comments in RebaseChangeOp.
+        PatchSet origPs = args.psUtil.get(ctx.getDb(),
+            toMerge.getControl().getNotes(), toMerge.getPatchsetId());
+        // TODO(tandrii): add extension point to customize commit message while
+        // rebasing.
+        rebaseOp = args.rebaseFactory.create(
+              toMerge.getControl(), origPs, args.mergeTip.getCurrentTip().name())
+            .setFireRevisionCreated(false)
+            // Bypass approval copier since SubmitStrategyOp copy all approvals
+            // later anyway.
+            .setCopyApprovals(false)
+            .setValidatePolicy(CommitValidators.Policy.NONE)
+            // Do not post message after inserting new patchset because there
+            // will be one about change being merged already.
+            .setPostMessage(false);
+        try {
+          rebaseOp.updateRepo(ctx);
+        } catch (MergeConflictException | NoSuchChangeException e) {
+          toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
+          throw new IntegrationException(
+              "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
+        }
+        newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
+        newPatchSetId = rebaseOp.getPatchSetId();
+      }
+      newCommit = amendGitlink(newCommit);
+      newCommit.copyFrom(toMerge);
+      newCommit.setPatchsetId(newPatchSetId);
+      newCommit.setStatusCode(CommitMergeStatus.CLEAN_REBASE);
+      args.mergeTip.moveTipTo(newCommit, newCommit);
+      args.commits.put(args.mergeTip.getCurrentTip());
+      acceptMergeTip(args.mergeTip);
+    }
+
+    @Override
+    public PatchSet updateChangeImpl(ChangeContext ctx)
+        throws NoSuchChangeException, ResourceConflictException,
+        OrmException, IOException  {
+      if (newCommit == null) {
+        checkState(!rebaseAlways, "RebaseAlways must never fast forward");
+        // Took the fast-forward option, nothing to do.
+        return null;
+      }
+      PatchSet newPs;
+      if (rebaseOp != null) {
+        rebaseOp.updateChange(ctx);
+        newPs = rebaseOp.getPatchSet();
+      } else {
+        // CherryPick
+        PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
+        newPs = args.psUtil.insert(ctx.getDb(), ctx.getRevWalk(),
+            ctx.getUpdate(newPatchSetId), newPatchSetId, newCommit, false,
+            prevPs != null ? prevPs.getGroups() : ImmutableList.<String> of(),
+            null);
+      }
+      ctx.getChange().setCurrentPatchSet(args.patchSetInfoFactory
+          .get(ctx.getRevWalk(), newCommit, newPatchSetId));
+      newCommit.setControl(ctx.getControl());
+      return newPs;
+    }
+
+    @Override
+    public void postUpdateImpl(Context ctx) throws OrmException {
+      if (rebaseOp != null) {
+        rebaseOp.postUpdate(ctx);
+      }
+    }
+  }
+
+  private class RebaseMultipleParentsOp extends SubmitStrategyOp {
+    private RebaseMultipleParentsOp(CodeReviewCommit toMerge) {
+      super(RebaseSubmitStrategy.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx)
+        throws IntegrationException, IOException {
+      // There are multiple parents, so this is a merge commit. We don't want
+      // to rebase the merge as clients can't easily rebase their history with
+      // that merge present and replaced by an equivalent merge with a different
+      // first parent. So instead behave as though MERGE_IF_NECESSARY was
+      // configured.
+      // TODO(tandrii): this is not in spirit of RebaseAlways strategy because
+      // the commit messages can not be modified in the process. It's also
+      // possible to implement rebasing of merge commits. E.g., the Cherry Pick
+      // REST endpoint already supports cherry-picking of merge commits.
+      // For now, users of RebaseAlways strategy for whom changed commit footers
+      // are important would be well advised to prohibit uploading patches with
+      // merge commits.
+      MergeTip mergeTip = args.mergeTip;
+      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge) &&
+          !args.submoduleOp.hasSubscription(args.destBranch)) {
+        mergeTip.moveTipTo(toMerge, toMerge);
+      } else {
+        CodeReviewCommit newTip = args.mergeUtil.mergeOneCommit(
+            args.serverIdent, args.serverIdent, args.repo, args.rw,
+            args.inserter, args.destBranch, mergeTip.getCurrentTip(), toMerge);
+        mergeTip.moveTipTo(amendGitlink(newTip), toMerge);
+      }
+      args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
+          mergeTip.getCurrentTip(), args.alreadyAccepted);
+      acceptMergeTip(mergeTip);
+    }
+  }
+
+  private void acceptMergeTip(MergeTip mergeTip) {
+    args.alreadyAccepted.add(mergeTip.getCurrentTip());
+  }
+
+  private List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort)
+      throws IntegrationException {
+    try {
+      return new RebaseSorter(
+          args.rw, args.alreadyAccepted, args.canMergeFlag).sort(toSort);
+    } catch (IOException e) {
+      throw new IntegrationException("Commit sorting failed", e);
+    }
+  }
+
+  static boolean dryRun(SubmitDryRun.Arguments args,
+      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+      throws IntegrationException {
+    // Test for merge instead of cherry pick to avoid false negatives
+    // on commit chains.
+    return !args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)
+        && args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip,
+             toMerge);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
index c784379..a7dc367 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -70,12 +69,7 @@
     return FluentIterable
         .from(repo.getRefDatabase().getRefs(Constants.R_HEADS).values())
         .append(repo.getRefDatabase().getRefs(Constants.R_TAGS).values())
-        .transform(new Function<Ref, ObjectId>() {
-          @Override
-          public ObjectId apply(Ref r) {
-            return r.getObjectId();
-          }
-        });
+        .transform(Ref::getObjectId);
   }
 
   public static Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw)
@@ -128,6 +122,8 @@
         return MergeIfNecessary.dryRun(args, tipCommit, toMergeCommit);
       case REBASE_IF_NECESSARY:
         return RebaseIfNecessary.dryRun(args, tipCommit, toMergeCommit);
+      case REBASE_ALWAYS:
+        return RebaseAlways.dryRun(args, tipCommit, toMergeCommit);
       default:
         String errorMsg = "No submit strategy for: " + submitType;
         log.error(errorMsg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
index 36de70e..28e170f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
@@ -96,7 +96,8 @@
           Set<RevCommit> alreadyAccepted,
           RequestId submissionId,
           NotifyHandling notifyHandling,
-          SubmoduleOp submoduleOp);
+          SubmoduleOp submoduleOp,
+          boolean dryrun);
     }
 
     final AccountCache accountCache;
@@ -133,6 +134,7 @@
     final ProjectState project;
     final MergeSorter mergeSorter;
     final MergeUtil mergeUtil;
+    final boolean dryrun;
 
     @AssistedInject
     Arguments(
@@ -165,7 +167,8 @@
         @Assisted RequestId submissionId,
         @Assisted SubmitType submitType,
         @Assisted NotifyHandling notifyHandling,
-        @Assisted SubmoduleOp submoduleOp) {
+        @Assisted SubmoduleOp submoduleOp,
+        @Assisted boolean dryrun) {
       this.accountCache = accountCache;
       this.approvalsUtil = approvalsUtil;
       this.batchUpdateFactory = batchUpdateFactory;
@@ -196,6 +199,7 @@
       this.submitType = submitType;
       this.notifyHandling = notifyHandling;
       this.submoduleOp = submoduleOp;
+      this.dryrun = dryrun;
 
       this.project = checkNotNull(projectCache.get(destBranch.getParentKey()),
             "project not found: %s", destBranch.getParentKey());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
index 6bb6fa6..f8ca32a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
@@ -54,12 +54,12 @@
       Repository repo, CodeReviewRevWalk rw, ObjectInserter inserter,
       RevFlag canMergeFlag, Set<RevCommit> alreadyAccepted,
       Branch.NameKey destBranch, IdentifiedUser caller, MergeTip mergeTip,
-      CommitStatus commits, RequestId submissionId, NotifyHandling notifyHandling,
-      SubmoduleOp submoduleOp)
+      CommitStatus commits, RequestId submissionId,
+      NotifyHandling notifyHandling, SubmoduleOp submoduleOp, boolean dryrun)
       throws IntegrationException {
     SubmitStrategy.Arguments args = argsFactory.create(submitType, destBranch,
         commits, rw, caller, mergeTip, inserter, repo, canMergeFlag, db,
-        alreadyAccepted, submissionId, notifyHandling, submoduleOp);
+        alreadyAccepted, submissionId, notifyHandling, submoduleOp, dryrun);
     switch (submitType) {
       case CHERRY_PICK:
         return new CherryPick(args);
@@ -71,6 +71,8 @@
         return new MergeIfNecessary(args);
       case REBASE_IF_NECESSARY:
         return new RebaseIfNecessary(args);
+      case REBASE_ALWAYS:
+        return new RebaseAlways(args);
       default:
         String errorMsg = "No submit strategy for: " + submitType;
         log.error(errorMsg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
index 72bfedf..8a303a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
@@ -33,7 +33,8 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -182,13 +183,7 @@
       }
     }
     Collections.sort(commits, ReviewDbUtil.intKeyOrdering().reverse()
-        .onResultOf(
-          new Function<CodeReviewCommit, PatchSet.Id>() {
-            @Override
-            public PatchSet.Id apply(CodeReviewCommit in) {
-              return in.getPatchsetId();
-            }
-          }));
+        .onResultOf(c -> c.getPatchsetId()));
     CodeReviewCommit result = MergeUtil.findAnyMergedInto(rw, commits, tip);
     if (result == null) {
       return null;
@@ -335,15 +330,9 @@
       byKey.put(psa.getKey(), psa);
     }
 
-    submitter = new PatchSetApproval(
-          new PatchSetApproval.Key(
-              psId,
-              ctx.getAccountId(),
-              LabelId.legacySubmit()),
-              (short) 1, ctx.getWhen());
+    submitter = ApprovalsUtil.newApproval(
+        psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
     byKey.put(submitter.getKey(), submitter);
-    submitter.setValue((short) 1);
-    submitter.setGranted(ctx.getWhen());
 
     // Flatten out existing approvals for this patch set based upon the current
     // permissions. Once the change is closed the approvals are not updated at
@@ -385,14 +374,11 @@
 
   private static Function<PatchSetApproval, PatchSetApproval>
       convertPatchSet(final PatchSet.Id psId) {
-    return new Function<PatchSetApproval, PatchSetApproval>() {
-      @Override
-      public PatchSetApproval apply(PatchSetApproval in) {
-        if (in.getPatchSetId().equals(psId)) {
-          return in;
-        }
-        return new PatchSetApproval(psId, in);
+    return psa -> {
+      if (psa.getPatchSetId().equals(psId)) {
+        return psa;
       }
+      return new PatchSetApproval(psId, psa);
     };
   }
 
@@ -403,14 +389,12 @@
 
   private static Iterable<PatchSetApproval> zero(
       Iterable<PatchSetApproval> approvals) {
-    return Iterables.transform(approvals,
-        new Function<PatchSetApproval, PatchSetApproval>() {
-          @Override
-          public PatchSetApproval apply(PatchSetApproval in) {
-            PatchSetApproval copy = new PatchSetApproval(in.getPatchSetId(), in);
-            copy.setValue((short) 0);
-            return copy;
-          }
+    return Iterables.transform(
+        approvals,
+        a -> {
+          PatchSetApproval copy = new PatchSetApproval(a.getPatchSetId(), a);
+          copy.setValue((short) 0);
+          return copy;
         });
   }
 
@@ -426,7 +410,7 @@
   }
 
   private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit,
-      CommitMergeStatus s) {
+      CommitMergeStatus s) throws OrmException {
     checkNotNull(s, "CommitMergeStatus may not be null");
     String txt = s.getMessage();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
@@ -448,6 +432,7 @@
         case CHERRY_PICK:
           return message(ctx, commit, CommitMergeStatus.CLEAN_PICK);
         case REBASE_IF_NECESSARY:
+        case REBASE_ALWAYS:
           return message(ctx, commit, CommitMergeStatus.CLEAN_REBASE);
         default:
           throw new IllegalStateException("unexpected submit type "
@@ -463,19 +448,10 @@
   }
 
   private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId,
-      String body) {
-    checkNotNull(psId);
-    String uuid;
-    try {
-      uuid = ChangeUtil.messageUUID(ctx.getDb());
-    } catch (OrmException e) {
-      return null;
-    }
-    ChangeMessage m = new ChangeMessage(
-        new ChangeMessage.Key(psId.getParentKey(), uuid),
-        ctx.getAccountId(), ctx.getWhen(), psId);
-    m.setMessage(body);
-    return m;
+      String body) throws OrmException {
+    return ChangeMessagesUtil.newMessage(
+        ctx.getDb(), psId, ctx.getUser(), ctx.getWhen(), body,
+        ChangeMessagesUtil.TAG_MERGED);
   }
 
   private void setMerged(ChangeContext ctx, ChangeMessage msg)
@@ -524,7 +500,7 @@
     } catch (Exception e) {
       log.error("Cannot email merged notification for " + getId(), e);
     }
-    if (mergeResultRev != null) {
+    if (mergeResultRev != null && !args.dryrun) {
       args.changeMerged.fire(
           updatedChange,
           mergedPatchSet,
@@ -564,26 +540,18 @@
    */
   protected CodeReviewCommit amendGitlink(CodeReviewCommit commit)
       throws IntegrationException {
-    CodeReviewCommit newCommit = commit;
-    // Modify the commit with gitlink update
-    if (args.submoduleOp.hasSubscription(args.destBranch)) {
-      try {
-        newCommit =
-            args.submoduleOp.composeGitlinksCommit(args.destBranch, commit);
-        newCommit.copyFrom(commit);
-        if (commit.equals(toMerge)) {
-          newCommit.setPatchsetId(ChangeUtil.nextPatchSetId(
-              args.repo, toMerge.change().currentPatchSetId()));
-          args.commits.put(newCommit);
-        }
-      } catch (SubmoduleException | IOException e) {
-        throw new IntegrationException(
-            "cannot update gitlink for the commit at branch: "
-                + args.destBranch);
-      }
+    if (!args.submoduleOp.hasSubscription(args.destBranch)) {
+      return commit;
     }
 
-    return newCommit;
+    // Modify the commit with gitlink update
+    try {
+      return args.submoduleOp.composeGitlinksCommit(args.destBranch, commit);
+    } catch (SubmoduleException | IOException e) {
+      throw new IntegrationException(
+          "cannot update gitlink for the commit at branch: "
+              + args.destBranch);
+    }
   }
 
   protected final void logDebug(String msg, Object... args) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
index d4956ab..9cb09cc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -16,8 +16,10 @@
 
 import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
+import static com.google.gerrit.server.git.ReceiveCommits.NEW_PATCHSET;
 
 import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
@@ -31,15 +33,15 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.ReceiveCommits;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 
 import com.jcraft.jsch.HostKey;
 
@@ -51,6 +53,7 @@
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.FooterLine;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -68,107 +71,124 @@
       .getLogger(CommitValidators.class);
 
   public enum Policy {
-    /** Use {@link #validateForGerritCommits}. */
+    /** Use {@link Factory#forGerritCommits}. */
     GERRIT,
 
-    /** Use {@link #validateForReceiveCommits}. */
+    /** Use {@link Factory#forReceiveCommits}. */
     RECEIVE_COMMITS,
 
+    /** Use {@link Factory#forMergedCommits}. */
+    MERGED,
+
     /** Do not validate commits. */
     NONE
   }
 
-  public interface Factory {
-    CommitValidators create(RefControl refControl, SshInfo sshInfo,
-        Repository repo);
-  }
+  @Singleton
+  public static class Factory {
+    private final PersonIdent gerritIdent;
+    private final String canonicalWebUrl;
+    private final DynamicSet<CommitValidationListener> pluginValidators;
+    private final AllUsersName allUsers;
+    private final String installCommitMsgHookCommand;
 
-  private final PersonIdent gerritIdent;
-  private final RefControl refControl;
-  private final String canonicalWebUrl;
-  private final String installCommitMsgHookCommand;
-  private final SshInfo sshInfo;
-  private final Repository repo;
-  private final DynamicSet<CommitValidationListener> commitValidationListeners;
-  private final AllUsersName allUsers;
-
-  @Inject
-  CommitValidators(@GerritPersonIdent PersonIdent gerritIdent,
-      @CanonicalWebUrl @Nullable String canonicalWebUrl,
-      @GerritServerConfig Config config,
-      DynamicSet<CommitValidationListener> commitValidationListeners,
-      AllUsersName allUsers,
-      @Assisted SshInfo sshInfo,
-      @Assisted Repository repo,
-      @Assisted RefControl refControl) {
-    this.gerritIdent = gerritIdent;
-    this.canonicalWebUrl = canonicalWebUrl;
-    this.installCommitMsgHookCommand =
-        config.getString("gerrit", null, "installCommitMsgHookCommand");
-    this.commitValidationListeners = commitValidationListeners;
-    this.allUsers = allUsers;
-    this.sshInfo = sshInfo;
-    this.repo = repo;
-    this.refControl = refControl;
-  }
-
-  public List<CommitValidationMessage> validateForReceiveCommits(
-      CommitReceivedEvent receiveEvent, NoteMap rejectCommits)
-      throws CommitValidationException {
-
-    List<CommitValidationListener> validators = new LinkedList<>();
-
-    validators.add(new UploadMergesPermissionValidator(refControl));
-    validators.add(new AmendedGerritMergeCommitValidationListener(
-        refControl, gerritIdent));
-    validators.add(new AuthorUploaderValidator(refControl, canonicalWebUrl));
-    validators.add(new CommitterUploaderValidator(refControl, canonicalWebUrl));
-    validators.add(new SignedOffByValidator(refControl));
-    if (MagicBranch.isMagicBranch(receiveEvent.command.getRefName())
-        || ReceiveCommits.NEW_PATCHSET.matcher(
-            receiveEvent.command.getRefName()).matches()) {
-      validators.add(new ChangeIdValidator(refControl, canonicalWebUrl,
-          installCommitMsgHookCommand, sshInfo));
+    @Inject
+    Factory(@GerritPersonIdent PersonIdent gerritIdent,
+        @CanonicalWebUrl @Nullable String canonicalWebUrl,
+        @GerritServerConfig Config cfg,
+        DynamicSet<CommitValidationListener> pluginValidators,
+        AllUsersName allUsers) {
+      this.gerritIdent = gerritIdent;
+      this.canonicalWebUrl = canonicalWebUrl;
+      this.pluginValidators = pluginValidators;
+      this.allUsers = allUsers;
+      this.installCommitMsgHookCommand = cfg != null
+          ? cfg.getString("gerrit", null, "installCommitMsgHookCommand") : null;
     }
-    validators.add(new ConfigValidator(refControl, repo, allUsers));
-    validators.add(new BannedCommitsValidator(rejectCommits));
-    validators.add(new PluginCommitValidationListener(commitValidationListeners));
 
-    List<CommitValidationMessage> messages = new LinkedList<>();
-
-    try {
-      for (CommitValidationListener commitValidator : validators) {
-        messages.addAll(commitValidator.onCommitReceived(receiveEvent));
+    public CommitValidators create(Policy policy, RefControl refControl,
+        SshInfo sshInfo, Repository repo) throws IOException {
+      switch (policy) {
+        case RECEIVE_COMMITS:
+          return forReceiveCommits(refControl, sshInfo, repo);
+        case GERRIT:
+          return forGerritCommits(refControl, sshInfo, repo);
+        case MERGED:
+          return forMergedCommits(refControl);
+        case NONE:
+          return none();
+        default:
+          throw new IllegalArgumentException("unspported policy: " + policy);
       }
-    } catch (CommitValidationException e) {
-      // Keep the old messages (and their order) in case of an exception
-      messages.addAll(e.getMessages());
-      throw new CommitValidationException(e.getMessage(), messages);
     }
-    return messages;
+
+    private CommitValidators forReceiveCommits(RefControl refControl,
+        SshInfo sshInfo, Repository repo) throws IOException {
+      try (RevWalk rw = new RevWalk(repo)) {
+        NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
+        return new CommitValidators(ImmutableList.of(
+            new UploadMergesPermissionValidator(refControl),
+            new AmendedGerritMergeCommitValidationListener(
+                refControl, gerritIdent),
+            new AuthorUploaderValidator(refControl, canonicalWebUrl),
+            new CommitterUploaderValidator(refControl, canonicalWebUrl),
+            new SignedOffByValidator(refControl),
+            new ChangeIdValidator(refControl, canonicalWebUrl,
+                installCommitMsgHookCommand, sshInfo),
+            new ConfigValidator(refControl, repo, allUsers),
+            new BannedCommitsValidator(rejectCommits),
+            new PluginCommitValidationListener(pluginValidators)));
+      }
+    }
+
+    private CommitValidators forGerritCommits(RefControl refControl,
+        SshInfo sshInfo, Repository repo) {
+      return new CommitValidators(ImmutableList.of(
+          new UploadMergesPermissionValidator(refControl),
+          new AmendedGerritMergeCommitValidationListener(
+              refControl, gerritIdent),
+          new AuthorUploaderValidator(refControl, canonicalWebUrl),
+          new SignedOffByValidator(refControl),
+          new ChangeIdValidator(refControl, canonicalWebUrl,
+                installCommitMsgHookCommand, sshInfo),
+          new ConfigValidator(refControl, repo, allUsers),
+          new PluginCommitValidationListener(pluginValidators)));
+    }
+
+    private CommitValidators forMergedCommits(RefControl refControl) {
+      // Generally only include validators that are based on permissions of the
+      // user creating a change for a merged commit; generally exclude
+      // validators that would require amending the change in order to correct.
+      //
+      // Examples:
+      //  - Change-Id and Signed-off-by can't be added to an already-merged
+      //    commit.
+      //  - If the commit is banned, we can't ban it here. In fact, creating a
+      //    review of a previously merged and recently-banned commit is a use
+      //    case for post-commit code review: so reviewers have a place to
+      //    discuss what to do about it.
+      //  - Plugin validators may do things like require certain commit message
+      //    formats, so we play it safe and exclude them.
+      return new CommitValidators(ImmutableList.of(
+          new UploadMergesPermissionValidator(refControl),
+          new AuthorUploaderValidator(refControl, canonicalWebUrl),
+          new CommitterUploaderValidator(refControl, canonicalWebUrl)));
+    }
+
+    private CommitValidators none() {
+      return new CommitValidators(ImmutableList.<CommitValidationListener>of());
+    }
   }
 
-  public List<CommitValidationMessage> validateForGerritCommits(
+  private final List<CommitValidationListener> validators;
+
+  CommitValidators(List<CommitValidationListener> validators) {
+    this.validators = validators;
+  }
+
+  public List<CommitValidationMessage> validate(
       CommitReceivedEvent receiveEvent) throws CommitValidationException {
-
-    List<CommitValidationListener> validators = new LinkedList<>();
-
-    validators.add(new UploadMergesPermissionValidator(refControl));
-    validators.add(new AmendedGerritMergeCommitValidationListener(
-        refControl, gerritIdent));
-    validators.add(new AuthorUploaderValidator(refControl, canonicalWebUrl));
-    validators.add(new SignedOffByValidator(refControl));
-    if (MagicBranch.isMagicBranch(receiveEvent.command.getRefName())
-        || ReceiveCommits.NEW_PATCHSET.matcher(
-            receiveEvent.command.getRefName()).matches()) {
-      validators.add(new ChangeIdValidator(refControl, canonicalWebUrl,
-          installCommitMsgHookCommand, sshInfo));
-    }
-    validators.add(new ConfigValidator(refControl, repo, allUsers));
-    validators.add(new PluginCommitValidationListener(commitValidationListeners));
-
     List<CommitValidationMessage> messages = new LinkedList<>();
-
     try {
       for (CommitValidationListener commitValidator : validators) {
         messages.addAll(commitValidator.onCommitReceived(receiveEvent));
@@ -221,6 +241,9 @@
     @Override
     public List<CommitValidationMessage> onCommitReceived(
         CommitReceivedEvent receiveEvent) throws CommitValidationException {
+      if (!shouldValidateChangeId(receiveEvent)) {
+        return Collections.emptyList();
+      }
       RevCommit commit = receiveEvent.commit;
       List<CommitValidationMessage> messages = new LinkedList<>();
       List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
@@ -255,6 +278,11 @@
       return Collections.emptyList();
     }
 
+    private static boolean shouldValidateChangeId(CommitReceivedEvent event) {
+      return MagicBranch.isMagicBranch(event.command.getRefName())
+          || NEW_PATCHSET.matcher(event.command.getRefName()).matches();
+    }
+
     private CommitValidationMessage getMissingChangeIdErrorMsg(
         final String errMsg, final RevCommit c) {
       StringBuilder sb = new StringBuilder();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
index bd74fff..c10b279 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.gerrit.audit.AuditService;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
@@ -27,7 +28,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
index d23cac4..85c3d15 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
@@ -45,6 +45,7 @@
 import org.kohsuke.args4j.Option;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashMap;
@@ -314,11 +315,11 @@
     return groups;
   }
 
-  private List<AccountGroup> filterGroups(final Iterable<AccountGroup> groups) {
-    final List<AccountGroup> filteredGroups = new ArrayList<>();
-    final boolean isAdmin =
+  private List<AccountGroup> filterGroups(Collection<AccountGroup> groups) {
+    List<AccountGroup> filteredGroups = new ArrayList<>(groups.size());
+    boolean isAdmin =
         identifiedUser.get().getCapabilities().canAdministrateServer();
-    for (final AccountGroup group : groups) {
+    for (AccountGroup group : groups) {
       if (!Strings.isNullOrEmpty(matchSubstring)) {
         if (!group.getName().toLowerCase(Locale.US)
             .contains(matchSubstring.toLowerCase(Locale.US))) {
@@ -326,7 +327,7 @@
         }
       }
       if (!isAdmin) {
-        final GroupControl c = groupControlFactory.controlFor(group);
+        GroupControl c = groupControlFactory.controlFor(group);
         if (!c.isVisible()) {
           continue;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
index d5d90d3..9e0be86 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 
-import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
@@ -56,7 +55,7 @@
  */
 public class IndexModule extends LifecycleModule {
   public enum IndexType {
-    LUCENE
+    LUCENE, ELASTICSEARCH
   }
 
   public static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
@@ -110,19 +109,11 @@
             accounts,
             changes);
     Set<String> expected = FluentIterable.from(ALL_SCHEMA_DEFS)
-        .transform(new Function<SchemaDefinitions<?>, String>() {
-          @Override
-          public String apply(SchemaDefinitions<?> in) {
-            return in.getName();
-          }
-        }).toSet();
+        .transform(SchemaDefinitions::getName)
+        .toSet();
     Set<String> actual = FluentIterable.from(result)
-        .transform(new Function<IndexDefinition<?, ?, ?>, String>() {
-          @Override
-          public String apply(IndexDefinition<?, ?, ?> in) {
-            return in.getName();
-          }
-        }).toSet();
+        .transform(IndexDefinition::getName)
+        .toSet();
     if (!expected.equals(actual)) {
       throw new ProvisionException(
           "need index definitions for all schemas: "
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
index 10f5ecb..faa6934 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
@@ -18,7 +18,6 @@
 
 import com.google.common.base.Function;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Optional;
 import com.google.common.base.Predicates;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
@@ -33,6 +32,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 
 /** Specific version of a secondary index schema. */
 public class Schema<T> {
@@ -149,15 +149,15 @@
       FieldDef<T, ?>... rest) {
     FieldDef<T, ?> field = fields.get(first.getName());
     if (field != null) {
-      return Optional.<FieldDef<T, ?>> of(checkSame(field, first));
+      return Optional.of(checkSame(field, first));
     }
     for (FieldDef<T, ?> f : rest) {
       field = fields.get(f.getName());
       if (field != null) {
-        return Optional.<FieldDef<T, ?>> of(checkSame(field, f));
+        return Optional.of(checkSame(field, f));
       }
     }
-    return Optional.absent();
+    return Optional.empty();
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
index 824739e..afe3f70 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
@@ -14,14 +14,12 @@
 
 package com.google.gerrit.server.index.account;
 
-import com.google.common.base.Function;
 import com.google.common.base.Predicates;
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldType;
 import com.google.gerrit.server.index.SchemaUtil;
@@ -47,13 +45,7 @@
         @Override
         public Iterable<String> get(AccountState input, FillArgs args) {
           return Iterables.transform(
-              input.getExternalIds(),
-              new Function<AccountExternalId, String>() {
-                @Override
-                public String apply(AccountExternalId in) {
-                  return in.getKey().get();
-                }
-              });
+              input.getExternalIds(), id -> id.getKey().get());
         }
       };
 
@@ -68,12 +60,7 @@
               fullName,
               Iterables.transform(
                   input.getExternalIds(),
-                  new Function<AccountExternalId, String>() {
-                    @Override
-                    public String apply(AccountExternalId in) {
-                      return in.getEmailAddress();
-                    }
-                  }));
+                  AccountExternalId::getEmailAddress));
 
           // Additional values not currently added by getPersonParts.
           // TODO(dborowitz): Move to getPersonParts and remove this hack.
@@ -108,23 +95,11 @@
         @Override
         public Iterable<String> get(AccountState input, FillArgs args) {
           return FluentIterable.from(input.getExternalIds())
-            .transform(
-                new Function<AccountExternalId, String>() {
-                  @Override
-                  public String apply(AccountExternalId in) {
-                    return in.getEmailAddress();
-                  }
-                })
+            .transform(AccountExternalId::getEmailAddress)
             .append(
                 Collections.singleton(input.getAccount().getPreferredEmail()))
             .filter(Predicates.notNull())
-            .transform(
-                new Function<String, String>() {
-                  @Override
-                  public String apply(String in) {
-                    return in.toLowerCase();
-                  }
-                })
+            .transform(String::toLowerCase)
             .toSet();
         }
       };
@@ -153,12 +128,8 @@
         @Override
         public Iterable<String> get(AccountState input, FillArgs args) {
           return FluentIterable.from(input.getProjectWatches().keySet())
-              .transform(new Function<ProjectWatchKey, String>() {
-            @Override
-            public String apply(ProjectWatchKey in) {
-              return in.project().get();
-            }
-          }).toSet();
+              .transform(k -> k.project().get())
+              .toSet();
         }
       };
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index d659215..22416ac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -222,6 +222,10 @@
         try (Repository repo = repoManager.openRepository(project);
             ReviewDb db = schemaFactory.open()) {
           Map<String, Ref> refs = repo.getRefDatabase().getRefs(ALL);
+          // TODO(dborowitz): Pre-loading all notes is almost certainly a
+          // terrible idea for performance. If we can get rid of walking by
+          // commit (see note below), then all we need to discover here is the
+          // change IDs.
           for (ChangeNotes cn : notesFactory.scan(repo, db, project)) {
             Ref r = refs.get(cn.getChange().currentPatchSetId().toRefName());
             if (r != null) {
@@ -290,6 +294,9 @@
           }
         }
 
+        // TODO(dborowitz): This is basically pointless; it computes
+        // currentFilePaths faster than going through PatchListCache, but we
+        // still need to go through PatchListCache for changedLines.
         RevCommit bCommit;
         while ((bCommit = walk.next()) != null && !byId.isEmpty()) {
           if (byId.containsKey(bCommit)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
index fe448c6..225b756 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -28,23 +29,25 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
+import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldType;
 import com.google.gerrit.server.index.SchemaUtil;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gson.Gson;
 import com.google.gwtorm.protobuf.CodecFactory;
 import com.google.gwtorm.protobuf.ProtobufCodec;
 import com.google.gwtorm.server.OrmException;
@@ -75,6 +78,10 @@
  * characters.
  */
 public class ChangeField {
+  public static final int NO_ASSIGNEE = -1;
+
+  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
+
   /** Legacy change ID. */
   public static final FieldDef<ChangeData, Integer> LEGACY_ID =
       new FieldDef.Single<ChangeData, Integer>("legacy_id",
@@ -247,13 +254,9 @@
         @Override
         public Iterable<String> get(ChangeData input, FillArgs args)
             throws OrmException {
-          return ImmutableSet.copyOf(Iterables.transform(input.hashtags(),
-              new Function<String, String>() {
-            @Override
-            public String apply(String input) {
-              return input.toLowerCase();
-            }
-          }));
+          return input.hashtags().stream()
+              .map(String::toLowerCase)
+              .collect(toSet());
         }
       };
 
@@ -264,13 +267,9 @@
         @Override
         public Iterable<byte[]> get(ChangeData input, FillArgs args)
             throws OrmException {
-          return ImmutableSet.copyOf(Iterables.transform(input.hashtags(),
-              new Function<String, byte[]>() {
-            @Override
-            public byte[] apply(String hashtag) {
-              return hashtag.getBytes(UTF_8);
-            }
-          }));
+          return input.hashtags().stream()
+              .map(t -> t.getBytes(UTF_8))
+              .collect(toSet());
         }
       };
 
@@ -300,26 +299,15 @@
         }
       };
 
-  /** Reviewer(s) associated with the change. */
-  @Deprecated
-  public static final FieldDef<ChangeData, Iterable<Integer>> LEGACY_REVIEWER =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_REVIEWER, FieldType.INTEGER, false) {
+  /** The user assigned to the change. */
+  public static final FieldDef<ChangeData, Integer> ASSIGNEE =
+      new FieldDef.Single<ChangeData, Integer>(
+          ChangeQueryBuilder.FIELD_ASSIGNEE, FieldType.INTEGER, false) {
         @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
+        public Integer get(ChangeData input, FillArgs args)
             throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return ImmutableSet.of();
-          }
-          Set<Integer> r = new HashSet<>();
-          if (!args.allowsDrafts && c.getStatus() == Change.Status.DRAFT) {
-            return r;
-          }
-          for (PatchSetApproval a : input.approvals().values()) {
-            r.add(a.getAccountId().get());
-          }
-          return r;
+          Account.Id id = input.change().getAssignee();
+          return id != null ? id.get() : NO_ASSIGNEE;
         }
       };
 
@@ -424,26 +412,47 @@
       };
 
   /** List of labels on the current patch set. */
+  @Deprecated
   public static final FieldDef<ChangeData, Iterable<String>> LABEL =
       new FieldDef.Repeatable<ChangeData, String>(
           ChangeQueryBuilder.FIELD_LABEL, FieldType.EXACT, false) {
         @Override
         public Iterable<String> get(ChangeData input, FillArgs args)
             throws OrmException {
-          Set<String> allApprovals = new HashSet<>();
-          Set<String> distinctApprovals = new HashSet<>();
-          for (PatchSetApproval a : input.currentApprovals()) {
-            if (a.getValue() != 0 && !a.isLegacySubmit()) {
-              allApprovals.add(formatLabel(a.getLabel(), a.getValue(),
-                  a.getAccountId()));
-              distinctApprovals.add(formatLabel(a.getLabel(), a.getValue()));
-            }
-          }
-          allApprovals.addAll(distinctApprovals);
-          return allApprovals;
+          return getLabels(input, false);
         }
       };
 
+  /** List of labels on the current patch set including change owner votes. */
+  public static final FieldDef<ChangeData, Iterable<String>> LABEL2 =
+      new FieldDef.Repeatable<ChangeData, String>(
+          "label2", FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return getLabels(input, true);
+        }
+      };
+
+  private static Iterable<String> getLabels(ChangeData input, boolean owners)
+      throws OrmException {
+    Set<String> allApprovals = new HashSet<>();
+    Set<String> distinctApprovals = new HashSet<>();
+    for (PatchSetApproval a : input.currentApprovals()) {
+      if (a.getValue() != 0 && !a.isLegacySubmit()) {
+        allApprovals.add(formatLabel(a.getLabel(), a.getValue(),
+            a.getAccountId()));
+        if (owners && input.change().getOwner().equals(a.getAccountId())) {
+          allApprovals.add(formatLabel(a.getLabel(), a.getValue(),
+              ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+        }
+        distinctApprovals.add(formatLabel(a.getLabel(), a.getValue()));
+      }
+    }
+    allApprovals.addAll(distinctApprovals);
+    return allApprovals;
+  }
+
   public static Set<String> getAuthorParts(ChangeData cd) throws OrmException {
     try {
       return SchemaUtil.getPersonParts(cd.getAuthor());
@@ -539,7 +548,14 @@
 
   public static String formatLabel(String label, int value, Account.Id accountId) {
     return label.toLowerCase() + (value >= 0 ? "+" : "") + value
-        + (accountId != null ? "," + accountId.get() : "");
+        + (accountId != null ? "," + formatAccount(accountId) : "");
+  }
+
+  private static String formatAccount(Account.Id accountId) {
+    if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) {
+      return ChangeQueryBuilder.ARG_ID_OWNER;
+    }
+    return Integer.toString(accountId.get());
   }
 
   /** Commit message of the current patch set. */
@@ -564,8 +580,8 @@
         public Iterable<String> get(ChangeData input, FillArgs args)
             throws OrmException {
           Set<String> r = new HashSet<>();
-          for (PatchLineComment c : input.publishedComments()) {
-            r.add(c.getMessage());
+          for (Comment c : input.publishedComments()) {
+            r.add(c.message);
           }
           for (ChangeMessage m : input.messages()) {
             r.add(m.getMessage());
@@ -622,10 +638,9 @@
         @Override
         public Integer get(ChangeData input, FillArgs args)
             throws OrmException {
-          Optional<ChangedLines> changedLines = input.changedLines();
-          return changedLines.isPresent()
-              ? changedLines.get().insertions + changedLines.get().deletions
-              : null;
+          return input.changedLines()
+              .map(c -> c.insertions + c.deletions)
+              .orElse(null);
         }
       };
 
@@ -642,31 +657,13 @@
               r.add(m.getAuthor().get());
             }
           }
-          for (PatchLineComment c : input.publishedComments()) {
-            r.add(c.getAuthor().get());
+          for (Comment c : input.publishedComments()) {
+            r.add(c.author.getId().get());
           }
           return r;
         }
       };
 
-  /** Users who have starred this change. */
-  @Deprecated
-  public static final FieldDef<ChangeData, Iterable<Integer>> STARREDBY =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_STARREDBY, FieldType.INTEGER, true) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return Iterables.transform(input.starredBy(),
-              new Function<Account.Id, Integer>() {
-            @Override
-            public Integer apply(Account.Id accountId) {
-              return accountId.get();
-            }
-          });
-        }
-      };
-
   /**
    * Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt;
    */
@@ -676,14 +673,12 @@
         @Override
         public Iterable<String> get(ChangeData input, FillArgs args)
             throws OrmException {
-          return Iterables.transform(input.stars().entries(),
-              new Function<Map.Entry<Account.Id, String>, String>() {
-            @Override
-            public String apply(Map.Entry<Account.Id, String> e) {
-              return StarredChangesUtil.StarField.create(
-                  e.getKey(), e.getValue()).toString();
-            }
-          });
+          return Iterables.transform(
+              input.stars().entries(),
+              (Map.Entry<Account.Id, String> e) -> {
+                return StarredChangesUtil.StarField.create(
+                    e.getKey(), e.getValue()).toString();
+              });
         }
       };
 
@@ -694,8 +689,7 @@
         @Override
         public Iterable<Integer> get(ChangeData input, FillArgs args)
             throws OrmException {
-          return Iterables.transform(input.stars().keySet(),
-              ReviewDbUtil.INT_KEY_FUNCTION);
+          return Iterables.transform(input.stars().keySet(), Account.Id::get);
         }
       };
 
@@ -740,13 +734,9 @@
         @Override
         public Iterable<Integer> get(ChangeData input, FillArgs args)
             throws OrmException {
-          return ImmutableSet.copyOf(Iterables.transform(input.editsByUser(),
-              new Function<Account.Id, Integer>() {
-            @Override
-            public Integer apply(Account.Id account) {
-              return account.get();
-            }
-          }));
+          return input.editsByUser().stream()
+              .map(Account.Id::get)
+              .collect(toSet());
         }
       };
 
@@ -758,13 +748,9 @@
         @Override
         public Iterable<Integer> get(ChangeData input, FillArgs args)
             throws OrmException {
-          return ImmutableSet.copyOf(Iterables.transform(input.draftsByUser(),
-              new Function<Account.Id, Integer>() {
-            @Override
-            public Integer apply(Account.Id account) {
-              return account.get();
-            }
-          }));
+          return input.draftsByUser().stream()
+              .map(Account.Id::get)
+              .collect(toSet());
         }
       };
 
@@ -796,6 +782,169 @@
         }
       };
 
+  // Submit rule options in this class should never use fastEvalLabels. This
+  // slows down indexing slightly but produces correct search results.
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
+      SubmitRuleOptions.defaults()
+          .allowClosed(true)
+          .allowDraft(true)
+          .build();
+
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
+      SubmitRuleOptions.defaults().build();
+
+  /**
+   * JSON type for storing SubmitRecords.
+   * <p>
+   * Stored fields need to use a stable format over a long period; this type
+   * insulates the index from implementation changes in SubmitRecord itself.
+   */
+  static class StoredSubmitRecord {
+    static class StoredLabel {
+      String label;
+      SubmitRecord.Label.Status status;
+      Integer appliedBy;
+    }
+
+    SubmitRecord.Status status;
+    List<StoredLabel> labels;
+    String errorMessage;
+
+    StoredSubmitRecord(SubmitRecord rec) {
+      this.status = rec.status;
+      this.errorMessage = rec.errorMessage;
+      if (rec.labels != null) {
+        this.labels = new ArrayList<>(rec.labels.size());
+        for (SubmitRecord.Label label : rec.labels) {
+          StoredLabel sl = new StoredLabel();
+          sl.label = label.label;
+          sl.status = label.status;
+          sl.appliedBy =
+              label.appliedBy != null ? label.appliedBy.get() : null;
+          this.labels.add(sl);
+        }
+      }
+    }
+
+    private SubmitRecord toSubmitRecord() {
+      SubmitRecord rec = new SubmitRecord();
+      rec.status = status;
+      rec.errorMessage = errorMessage;
+      if (labels != null) {
+        rec.labels = new ArrayList<>(labels.size());
+        for (StoredLabel label : labels) {
+          SubmitRecord.Label srl = new SubmitRecord.Label();
+          srl.label = label.label;
+          srl.status = label.status;
+          srl.appliedBy = label.appliedBy != null
+              ? new Account.Id(label.appliedBy)
+              : null;
+          rec.labels.add(srl);
+        }
+      }
+      return rec;
+    }
+  }
+
+  public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RECORD =
+      new FieldDef.Repeatable<ChangeData, String>(
+          "submit_record", FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return formatSubmitRecordValues(input);
+        }
+      };
+
+  public static final FieldDef<ChangeData, Iterable<byte[]>>
+      STORED_SUBMIT_RECORD_STRICT =
+          new FieldDef.Repeatable<ChangeData, byte[]>(
+              "full_submit_record_strict", FieldType.STORED_ONLY, true) {
+            @Override
+            public Iterable<byte[]> get(ChangeData input, FillArgs args)
+                throws OrmException {
+              return storedSubmitRecords(input, SUBMIT_RULE_OPTIONS_STRICT);
+            }
+          };
+
+  public static final FieldDef<ChangeData, Iterable<byte[]>>
+      STORED_SUBMIT_RECORD_LENIENT =
+          new FieldDef.Repeatable<ChangeData, byte[]>(
+              "full_submit_record_lenient", FieldType.STORED_ONLY, true) {
+            @Override
+            public Iterable<byte[]> get(ChangeData input, FillArgs args)
+                throws OrmException {
+              return storedSubmitRecords(input, SUBMIT_RULE_OPTIONS_LENIENT);
+            }
+          };
+
+  public static void parseSubmitRecords(
+      Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
+    checkArgument(!opts.fastEvalLabels());
+    List<SubmitRecord> records = parseSubmitRecords(values);
+    if (records.isEmpty()) {
+      // Assume no values means the field is not in the index;
+      // SubmitRuleEvaluator ensures the list is non-empty.
+      return;
+    }
+    out.setSubmitRecords(opts, records);
+
+    // Cache the fastEvalLabels variant as well so it can be used by
+    // ChangeJson.
+    out.setSubmitRecords(
+        opts.toBuilder().fastEvalLabels(true).build(),
+        records);
+  }
+
+  @VisibleForTesting
+  static List<SubmitRecord> parseSubmitRecords(Collection<String> values) {
+    return values.stream()
+        .map(v -> GSON.fromJson(v, StoredSubmitRecord.class).toSubmitRecord())
+        .collect(toList());
+  }
+
+  @VisibleForTesting
+  static List<byte[]> storedSubmitRecords(List<SubmitRecord> records) {
+    return Lists.transform(
+        records, r -> GSON.toJson(new StoredSubmitRecord(r)).getBytes(UTF_8));
+  }
+
+  private static Iterable<byte[]> storedSubmitRecords(
+      ChangeData cd, SubmitRuleOptions opts) throws OrmException {
+    return storedSubmitRecords(cd.submitRecords(opts));
+  }
+
+  public static List<String> formatSubmitRecordValues(ChangeData cd)
+      throws OrmException {
+    return formatSubmitRecordValues(
+        cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT),
+        cd.change().getOwner());
+  }
+
+  @VisibleForTesting
+  static List<String> formatSubmitRecordValues(List<SubmitRecord> records,
+      Account.Id changeOwner) {
+    List<String> result = new ArrayList<>();
+    for (SubmitRecord rec : records) {
+      result.add(rec.status.name());
+      if (rec.labels == null) {
+        continue;
+      }
+      for (SubmitRecord.Label label : rec.labels) {
+        String sl = label.status.toString() + ',' + label.label.toLowerCase();
+        result.add(sl);
+        String slc = sl + ',';
+        if (label.appliedBy != null) {
+          result.add(slc + label.appliedBy.get());
+          if (label.appliedBy.equals(changeOwner)) {
+            result.add(slc + ChangeQueryBuilder.OWNER_ACCOUNT_ID.get());
+          }
+        }
+      }
+    }
+    return result;
+  }
+
   public static final Integer NOT_REVIEWED = -1;
 
   private static String getTopic(ChangeData input) throws OrmException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index fa4f2fa..5ef548c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.index.change;
 
+import static com.google.gerrit.server.extensions.events.EventUtil.logEventListenerError;
+
 import com.google.common.base.Function;
 import com.google.common.util.concurrent.Atomics;
 import com.google.common.util.concurrent.CheckedFuture;
@@ -101,7 +103,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final ThreadLocalRequestContext context;
   private final ListeningExecutorService executor;
-  private final DynamicSet<ChangeIndexedListener> indexedListener;
+  private final DynamicSet<ChangeIndexedListener> indexedListeners;
 
   @AssistedInject
   ChangeIndexer(SchemaFactory<ReviewDb> schemaFactory,
@@ -109,7 +111,7 @@
       ChangeNotes.Factory changeNotesFactory,
       ChangeData.Factory changeDataFactory,
       ThreadLocalRequestContext context,
-      DynamicSet<ChangeIndexedListener> indexedListener,
+      DynamicSet<ChangeIndexedListener> indexedListeners,
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndex index) {
     this.executor = executor;
@@ -118,9 +120,9 @@
     this.changeNotesFactory = changeNotesFactory;
     this.changeDataFactory = changeDataFactory;
     this.context = context;
+    this.indexedListeners = indexedListeners;
     this.index = index;
     this.indexes = null;
-    this.indexedListener = indexedListener;
   }
 
   @AssistedInject
@@ -129,7 +131,7 @@
       ChangeNotes.Factory changeNotesFactory,
       ChangeData.Factory changeDataFactory,
       ThreadLocalRequestContext context,
-      DynamicSet<ChangeIndexedListener> indexedListener,
+      DynamicSet<ChangeIndexedListener> indexedListeners,
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndexCollection indexes) {
     this.executor = executor;
@@ -138,9 +140,9 @@
     this.changeNotesFactory = changeNotesFactory;
     this.changeDataFactory = changeDataFactory;
     this.context = context;
+    this.indexedListeners = indexedListeners;
     this.index = null;
     this.indexes = indexes;
-    this.indexedListener = indexedListener;
   }
 
   /**
@@ -184,14 +186,22 @@
   }
 
   private void fireChangeIndexedEvent(int id) {
-    for (ChangeIndexedListener listener : indexedListener) {
-      listener.onChangeIndexed(id);
+    for (ChangeIndexedListener listener : indexedListeners) {
+      try {
+        listener.onChangeIndexed(id);
+      } catch (Exception e) {
+        logEventListenerError(listener, e);
+      }
     }
   }
 
   private void fireChangeDeletedFromIndexEvent(int id) {
-    for (ChangeIndexedListener listener : indexedListener) {
-      listener.onChangeDeleted(id);
+    for (ChangeIndexedListener listener : indexedListeners) {
+      try {
+        listener.onChangeDeleted(id);
+      } catch (Exception e) {
+        logEventListenerError(listener, e);
+      }
     }
   }
 
@@ -327,6 +337,7 @@
       for (ChangeIndex i : getWriteIndexes()) {
         i.delete(id);
       }
+      log.info("Deleted change {} from index.", id.get());
       fireChangeDeletedFromIndexEvent(id.get());
       return null;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index c98d311..8a793e5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -22,7 +22,7 @@
 
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
   @Deprecated
-  static final Schema<ChangeData> V25 = schema(
+  static final Schema<ChangeData> V32 = schema(
       ChangeField.LEGACY_ID,
       ChangeField.ID,
       ChangeField.STATUS,
@@ -35,7 +35,6 @@
       ChangeField.FILE_PART,
       ChangeField.PATH,
       ChangeField.OWNER,
-      ChangeField.LEGACY_REVIEWER,
       ChangeField.COMMIT,
       ChangeField.TR,
       ChangeField.LABEL,
@@ -56,37 +55,29 @@
       ChangeField.REVIEWEDBY,
       ChangeField.EXACT_COMMIT,
       ChangeField.AUTHOR,
-      ChangeField.COMMITTER);
+      ChangeField.COMMITTER,
+      ChangeField.DRAFTBY,
+      ChangeField.HASHTAG_CASE_AWARE,
+      ChangeField.STAR,
+      ChangeField.STARBY,
+      ChangeField.REVIEWER);
 
   @Deprecated
-  static final Schema<ChangeData> V26 = schema(V25, ChangeField.DRAFTBY);
+  static final Schema<ChangeData> V33 =
+      schema(V32, ChangeField.ASSIGNEE);
 
   @Deprecated
-  static final Schema<ChangeData> V27 = schema(V26.getFields().values());
-
-  @Deprecated
-  static final Schema<ChangeData> V28 = schema(V27, ChangeField.STARREDBY);
-
-  @Deprecated
-  static final Schema<ChangeData> V29 =
-      schema(V28, ChangeField.HASHTAG_CASE_AWARE);
-
-  @Deprecated
-  static final Schema<ChangeData> V30 =
-      schema(V29, ChangeField.STAR, ChangeField.STARBY);
-
-  @Deprecated
-  static final Schema<ChangeData> V31 = new Schema.Builder<ChangeData>()
-      .add(V30)
-      .remove(ChangeField.STARREDBY)
+  static final Schema<ChangeData> V34 = new Schema.Builder<ChangeData>()
+      .add(V33)
+      .remove(ChangeField.LABEL)
+      .add(ChangeField.LABEL2)
       .build();
 
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V32 = new Schema.Builder<ChangeData>()
-      .add(V31)
-      .remove(ChangeField.LEGACY_REVIEWER)
-      .add(ChangeField.REVIEWER)
-      .build();
+  static final Schema<ChangeData> V35 =
+      schema(V34,
+          ChangeField.SUBMIT_RECORD,
+          ChangeField.STORED_SUBMIT_RECORD_LENIENT,
+          ChangeField.STORED_SUBMIT_RECORD_STRICT);
 
   public static final String NAME = "changes";
   public static final ChangeSchemaDefinitions INSTANCE =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 996caa7..3e0678d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.reviewdb.client.Change;
@@ -94,12 +93,9 @@
       public Iterator<ChangeData> iterator() {
         return Iterables.transform(
             rs,
-            new Function<ChangeData, ChangeData>() {
-              @Override
-              public ChangeData apply(ChangeData cd) {
-                fromSource.put(cd, currSource);
-                return cd;
-              }
+            cd -> {
+              fromSource.put(cd, currSource);
+              return cd;
             }).iterator();
       }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
index 863cb82..d4655f5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.mail;
 
+import com.google.gerrit.server.mail.send.EmailHeader;
+
 public class Address {
   public static Address parse(final String in) {
     final int lt = in.indexOf('<');
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
deleted file mode 100644
index b56b737..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
+++ /dev/null
@@ -1,286 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import static com.google.gerrit.server.PatchLineCommentsUtil.getCommentPsId;
-
-import com.google.common.base.Optional;
-import com.google.common.base.Strings;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.PatchLineCommentsUtil;
-import com.google.gerrit.server.patch.PatchFile;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Send comments, after the author of them hit used Publish Comments in the UI. */
-public class CommentSender extends ReplyToChangeSender {
-  private static final Logger log = LoggerFactory
-      .getLogger(CommentSender.class);
-
-  public interface Factory {
-    CommentSender create(Project.NameKey project, Change.Id id);
-  }
-
-  private List<PatchLineComment> inlineComments = Collections.emptyList();
-  private final PatchLineCommentsUtil plcUtil;
-
-  @Inject
-  public CommentSender(EmailArguments ea,
-      PatchLineCommentsUtil plcUtil,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id) throws OrmException {
-    super(ea, "comment", newChangeData(ea, project, id));
-    this.plcUtil = plcUtil;
-  }
-
-  public void setPatchLineComments(final List<PatchLineComment> plc)
-      throws OrmException {
-    inlineComments = plc;
-
-    Set<String> paths = new HashSet<>();
-    for (PatchLineComment c : plc) {
-      Patch.Key p = c.getKey().getParentKey();
-      if (!Patch.COMMIT_MSG.equals(p.getFileName())) {
-        paths.add(p.getFileName());
-      }
-    }
-    changeData.setCurrentFilePaths(Ordering.natural().sortedCopy(paths));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    if (notify.compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
-      ccAllApprovals();
-    }
-    if (notify.compareTo(NotifyHandling.ALL) >= 0) {
-      bccStarredBy();
-      includeWatchers(NotifyType.ALL_COMMENTS);
-    }
-  }
-
-  @Override
-  public void formatChange() throws EmailException {
-    appendText(velocifyFile("Comment.vm"));
-  }
-
-  @Override
-  public void formatFooter() throws EmailException {
-    appendText(velocifyFile("CommentFooter.vm"));
-  }
-
-  public boolean hasInlineComments() {
-    return !inlineComments.isEmpty();
-  }
-
-  public String getInlineComments() {
-    return getInlineComments(1);
-  }
-
-  public String getInlineComments(int lines) {
-    StringBuilder cmts = new StringBuilder();
-    try (Repository repo = getRepository()) {
-      PatchList patchList = null;
-      if (repo != null) {
-        try {
-          patchList = getPatchList();
-        } catch (PatchListNotAvailableException e) {
-          log.error("Failed to get patch list", e);
-        }
-      }
-
-      Patch.Key currentFileKey = null;
-      PatchFile currentFileData = null;
-      for (final PatchLineComment c : inlineComments) {
-        final Patch.Key pk = c.getKey().getParentKey();
-
-        if (!pk.equals(currentFileKey)) {
-          String link = makeLink(pk);
-          if (link != null) {
-            cmts.append(link).append('\n');
-          }
-          if (Patch.COMMIT_MSG.equals(pk.get())) {
-            cmts.append("Commit Message:\n\n");
-          } else {
-            cmts.append("File ").append(pk.get()).append(":\n\n");
-          }
-          currentFileKey = pk;
-
-          if (patchList != null) {
-            try {
-              currentFileData =
-                  new PatchFile(repo, patchList, pk.get());
-            } catch (IOException e) {
-              log.warn(String.format(
-                  "Cannot load %s from %s in %s",
-                  pk.getFileName(),
-                  patchList.getNewId().name(),
-                  projectState.getProject().getName()), e);
-              currentFileData = null;
-            }
-          }
-        }
-
-        if (currentFileData != null) {
-          appendComment(cmts, lines, currentFileData, c);
-        }
-        cmts.append("\n\n");
-      }
-    }
-    return cmts.toString();
-  }
-
-  private void appendComment(StringBuilder out, int contextLines,
-      PatchFile currentFileData, PatchLineComment comment) {
-    short side = comment.getSide();
-    CommentRange range = comment.getRange();
-    if (range != null) {
-      String prefix = "PS" + getCommentPsId(comment).get()
-        + ", Line " + range.getStartLine() + ": ";
-      for (int n = range.getStartLine(); n <= range.getEndLine(); n++) {
-        out.append(n == range.getStartLine()
-            ? prefix
-            : Strings.padStart(": ", prefix.length(), ' '));
-        try {
-          String s = currentFileData.getLine(side, n);
-          if (n == range.getStartLine() && n == range.getEndLine()) {
-            s = s.substring(
-                Math.min(range.getStartCharacter(), s.length()),
-                Math.min(range.getEndCharacter(), s.length()));
-          } else if (n == range.getStartLine()) {
-            s = s.substring(Math.min(range.getStartCharacter(), s.length()));
-          } else if (n == range.getEndLine()) {
-            s = s.substring(0, Math.min(range.getEndCharacter(), s.length()));
-          }
-          out.append(s);
-        } catch (Throwable e) {
-          // Don't quote the line if we can't safely convert it.
-        }
-        out.append('\n');
-      }
-      appendQuotedParent(out, comment);
-      out.append(comment.getMessage().trim()).append('\n');
-    } else {
-      int lineNbr = comment.getLine();
-      int maxLines;
-      try {
-        maxLines = currentFileData.getLineCount(side);
-      } catch (Throwable e) {
-        maxLines = lineNbr;
-      }
-
-      final int startLine = Math.max(1, lineNbr - contextLines + 1);
-      final int stopLine = Math.min(maxLines, lineNbr + contextLines);
-
-      for (int line = startLine; line <= lineNbr; ++line) {
-        appendFileLine(out, currentFileData, side, line);
-      }
-      appendQuotedParent(out, comment);
-      out.append(comment.getMessage().trim()).append('\n');
-
-      for (int line = lineNbr + 1; line < stopLine; ++line) {
-        appendFileLine(out, currentFileData, side, line);
-      }
-    }
-  }
-
-  private void appendFileLine(StringBuilder cmts, PatchFile fileData, short side, int line) {
-    cmts.append("Line " + line);
-    try {
-      final String lineStr = fileData.getLine(side, line);
-      cmts.append(": ");
-      cmts.append(lineStr);
-    } catch (Throwable e) {
-      // Don't quote the line if we can't safely convert it.
-    }
-    cmts.append("\n");
-  }
-
-  private void appendQuotedParent(StringBuilder out, PatchLineComment child) {
-    if (child.getParentUuid() != null) {
-      Optional<PatchLineComment> parent;
-      PatchLineComment.Key key = new PatchLineComment.Key(
-          child.getKey().getParentKey(),
-          child.getParentUuid());
-      try {
-        parent = plcUtil.get(args.db.get(), changeData.notes(), key);
-      } catch (OrmException e) {
-        log.warn("Could not find the parent of this comment: "
-            + child.toString());
-        parent = Optional.absent();
-      }
-      if (parent.isPresent()) {
-        String msg = parent.get().getMessage().trim();
-        if (msg.length() > 75) {
-          msg = msg.substring(0, 75);
-        }
-        int lf = msg.indexOf('\n');
-        if (lf > 0) {
-          msg = msg.substring(0, lf);
-        }
-        out.append("> ").append(msg).append('\n');
-      }
-    }
-  }
-
-  // Makes a link back to the given patch set and file.
-  private String makeLink(Patch.Key patch) {
-    String url = getGerritUrl();
-    if (url == null) {
-      return null;
-    }
-
-    PatchSet.Id ps = patch.getParentKey();
-    Change.Id c = ps.getParentKey();
-    return new StringBuilder()
-      .append(url)
-      .append("#/c/").append(c)
-      .append('/').append(ps.get())
-      .append('/').append(KeyUtil.encode(patch.get()))
-      .toString();
-  }
-
-  private Repository getRepository() {
-    try {
-      return args.server.openRepository(projectState.getProject().getNameKey());
-    } catch (IOException e) {
-      return null;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java
index 7ceb0ae..9bf97dd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java
@@ -15,6 +15,12 @@
 package com.google.gerrit.server.mail;
 
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.send.CommentSender;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.RestoredSender;
+import com.google.gerrit.server.mail.send.RevertedSender;
 
 public class EmailModule extends FactoryModule {
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSender.java
deleted file mode 100644
index a7a1028..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSender.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import com.google.gerrit.common.errors.EmailException;
-
-import java.util.Collection;
-import java.util.Map;
-
-/** Sends email messages to third parties. */
-public interface EmailSender {
-  boolean isEnabled();
-
-  /**
-   * Can the address receive messages from us?
-   *
-   * @param address the address to consider.
-   * @return true if this sender will deliver to the address.
-   */
-  boolean canEmail(String address);
-
-  /**
-   * Sends an email message.
-   *
-   * @param from who the message is from.
-   * @param rcpt one or more address where the message will be delivered to.
-   *        This list overrides any To or CC headers in {@code headers}.
-   * @param headers message headers.
-   * @param body text to appear in the body of the message.
-   * @throws EmailException the message cannot be sent.
-   */
-  void send(Address from, Collection<Address> rcpt,
-      Map<String, EmailHeader> headers, String body) throws EmailException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSettings.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSettings.java
index 3c14f2f..2f2fd8c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSettings.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSettings.java
@@ -22,11 +22,13 @@
 
 @Singleton
 public class EmailSettings {
+  public final boolean html;
   public final boolean includeDiff;
   public final int maximumDiffSize;
 
   @Inject
   EmailSettings(@GerritServerConfig Config cfg) {
+    html = cfg.getBoolean("sendemail", "html", true);
     includeDiff = cfg.getBoolean("sendemail", "includeDiff", false);
     maximumDiffSize = cfg.getInt("sendemail", "maximumDiffSize", 256 << 10);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
index 41e1e2c..488711a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 
 /** Verifies the token sent by {@link RegisterNewEmailSender}. */
 public interface EmailTokenVerifier {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
index 048a4a4..8a132cd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
@@ -32,6 +32,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.regex.Pattern;
 
 public class MailUtil {
   public static MailRecipients getRecipientsFromFooters(
@@ -124,4 +125,19 @@
       return Collections.unmodifiableSet(all);
     }
   }
+
+  /** allow wildcard matching for {@code domains} */
+  public static Pattern glob(String[] domains) {
+    // if domains is not set, match anything
+    if (domains == null || domains.length == 0) {
+      return Pattern.compile(".*");
+    }
+
+    StringBuilder sb = new StringBuilder("");
+    for (String domain : domains) {
+      String quoted = "\\Q" + domain.replace("\\E", "\\E\\\\E\\Q") + "\\E|";
+      sb.append(quoted.replace("*", "\\E.*\\Q"));
+    }
+    return Pattern.compile(sb.substring(0, sb.length() - 1));
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
index f12859f..3dd98ea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.gwtjsonrpc.server.ValidToken;
 import com.google.gwtjsonrpc.server.XsrfException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AbandonedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AbandonedSender.java
similarity index 84%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/AbandonedSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AbandonedSender.java
index 1e8bdf4..254d3f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AbandonedSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AbandonedSender.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
@@ -35,7 +35,7 @@
       @Assisted Project.NameKey project,
       @Assisted Change.Id id)
       throws OrmException {
-    super(ea, "abandon", newChangeData(ea, project, id));
+    super(ea, "abandon", ChangeEmail.newChangeData(ea, project, id));
   }
 
   @Override
@@ -50,6 +50,14 @@
 
   @Override
   protected void formatChange() throws EmailException {
-    appendText(velocifyFile("Abandoned.vm"));
+    appendText(textTemplate("Abandoned"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("AbandonedHtml"));
+    }
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddKeySender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
similarity index 81%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/AddKeySender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
index f825d1c..bbcd6a5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddKeySender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Joiner;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.RecipientType;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
@@ -80,7 +82,10 @@
 
   @Override
   protected void format() throws EmailException {
-    appendText(velocifyFile("AddKey.vm"));
+    appendText(textTemplate("AddKey"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("AddKeyHtml"));
+    }
   }
 
   public String getEmail() {
@@ -110,4 +115,19 @@
     }
     return null;
   }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("email", getEmail());
+    soyContextEmailData.put("gpgKeys", getGpgKeys());
+    soyContextEmailData.put("keyType", getKeyType());
+    soyContextEmailData.put("sshKey", getSshKey());
+    soyContextEmailData.put("userNameEmail", getUserNameEmail());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
similarity index 80%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/AddReviewerSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
index c9e42ad..79d02e0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddReviewerSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
@@ -12,9 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwtorm.server.OrmException;
@@ -24,15 +26,20 @@
 /** Asks a user to review a change. */
 public class AddReviewerSender extends NewChangeSender {
   public interface Factory {
-    AddReviewerSender create(Project.NameKey project, Change.Id id);
+    AddReviewerSender create(Project.NameKey project, Change.Id id,
+        NotifyHandling notify);
   }
 
   @Inject
   public AddReviewerSender(EmailArguments ea,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
+      @Assisted Change.Id id,
+      @Assisted @Nullable NotifyHandling notify)
       throws OrmException {
     super(ea, newChangeData(ea, project, id));
+    if (notify != null) {
+      setNotify(notify);
+    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
similarity index 85%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index badc706..73d77c0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -12,9 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
-
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+package com.google.gerrit.server.mail.send;
 
 import com.google.common.collect.Multimap;
 import com.google.gerrit.common.errors.EmailException;
@@ -31,7 +29,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.mail.ProjectWatch.Watchers;
+import com.google.gerrit.server.mail.RecipientType;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -55,6 +55,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
@@ -120,17 +121,9 @@
   @Override
   protected void format() throws EmailException {
     formatChange();
-    appendText(velocifyFile("ChangeFooter.vm"));
-    try {
-      TreeSet<String> names = new TreeSet<>();
-      for (Account.Id who : changeData.reviewers().all()) {
-        names.add(getNameEmailFor(who));
-      }
-      for (String name : names) {
-        appendText("Gerrit-Reviewer: " + name + "\n");
-      }
-    } catch (OrmException e) {
-      log.warn("Cannot get change reviewers", e);
+    appendText(textTemplate("ChangeFooter"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("ChangeFooterHtml"));
     }
     formatFooter();
   }
@@ -199,7 +192,7 @@
   }
 
   private void setChangeSubjectHeader() throws EmailException {
-    setHeader("Subject", velocifyFile("ChangeSubject.vm"));
+    setHeader("Subject", textTemplate("ChangeSubject"));
   }
 
   /** Get a link to the change; null if the server doesn't know its own address. */
@@ -255,7 +248,7 @@
         detail.append("---\n");
         PatchList patchList = getPatchList();
         for (PatchListEntry p : patchList.getPatches()) {
-          if (Patch.COMMIT_MSG.equals(p.getNewName())) {
+          if (Patch.isMagic(p.getNewName())) {
             continue;
           }
           detail.append(p.getChangeType().getCode())
@@ -372,7 +365,8 @@
     }
 
     try {
-      for (Account.Id id : changeData.reviewers().byState(REVIEWER)) {
+      for (Account.Id id : changeData.reviewers()
+          .byState(ReviewerStateInternal.REVIEWER)) {
         add(RecipientType.CC, id);
       }
     } catch (OrmException err) {
@@ -435,11 +429,65 @@
     velocityContext.put("patchSetInfo", patchSetInfo);
   }
 
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+
+    soyContext.put("changeId", change.getKey().get());
+    soyContext.put("coverLetter", getCoverLetter());
+    soyContext.put("fromName", getNameFor(fromId));
+
+    soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
+    soyContextEmailData.put("changeDetail", getChangeDetail());
+    soyContextEmailData.put("changeUrl", getChangeUrl());
+    soyContextEmailData.put("includeDiff", getIncludeDiff());
+
+    Map<String, String> changeData = new HashMap<>();
+    changeData.put("subject", change.getSubject());
+    changeData.put("originalSubject", change.getOriginalSubject());
+    changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
+    soyContext.put("change", changeData);
+
+    String subject = change.getSubject();
+    changeData.put("subject", subject);
+    // shortSubject is the subject limited to 63 characters, with an ellipsis if
+    // it exceeds that.
+    if (subject.length() < 64) {
+      changeData.put("shortSubject", subject);
+    } else {
+      changeData.put("shortSubject", subject.substring(0, 60) + "...");
+    }
+
+    Map<String, Object> patchSetData = new HashMap<>();
+    patchSetData.put("patchSetId", patchSet.getPatchSetId());
+    patchSetData.put("refName", patchSet.getRefName());
+    soyContext.put("patchSet", patchSetData);
+
+    soyContext.put("reviewerEmails",
+        getEmailsByState(ReviewerStateInternal.REVIEWER));
+    soyContext.put("ccEmails",
+        getEmailsByState(ReviewerStateInternal.CC));
+
+    // TODO(wyatta): patchSetInfo
+  }
+
+  private Set<String> getEmailsByState(ReviewerStateInternal state) {
+    Set<String> reviewers = new TreeSet<>();
+    try {
+      for (Account.Id who : changeData.reviewers().byState(state)) {
+        reviewers.add(getNameEmailFor(who));
+      }
+    } catch (OrmException e) {
+      log.warn("Cannot get change reviewers", e);
+    }
+    return reviewers;
+  }
+
   public boolean getIncludeDiff() {
     return args.settings.includeDiff;
   }
 
-  private static int HEAP_EST_SIZE = 32 * 1024;
+  private static final int HEAP_EST_SIZE = 32 * 1024;
 
   /** Show patch set as unified difference. */
   public String getUnifiedDiff() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
new file mode 100644
index 0000000..77086f7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -0,0 +1,560 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.common.data.FilenameComparator;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.common.errors.NoSuchEntityException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.patch.PatchFile;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/** Send comments, after the author of them hit used Publish Comments in the UI.
+ */
+public class CommentSender extends ReplyToChangeSender {
+  private static final Logger log = LoggerFactory
+      .getLogger(CommentSender.class);
+
+  public interface Factory {
+    CommentSender create(Project.NameKey project, Change.Id id);
+  }
+
+  private class FileCommentGroup {
+    public String filename;
+    public int patchSetId;
+    public PatchFile fileData;
+    public List<Comment> comments = new ArrayList<>();
+
+    /**
+     * @return a web link to the given patch set and file.
+     */
+    public String getLink() {
+      String url = getGerritUrl();
+      if (url == null) {
+        return null;
+      }
+
+      return new StringBuilder()
+        .append(url)
+        .append("#/c/").append(change.getId())
+        .append('/').append(patchSetId)
+        .append('/').append(KeyUtil.encode(filename))
+        .toString();
+    }
+
+    /**
+     * @return A title for the group, i.e. "Commit Message", "Merge List", or
+     * "File [[filename]]".
+     */
+    public String getTitle() {
+      if (Patch.COMMIT_MSG.equals(filename)) {
+        return "Commit Message";
+      } else if (Patch.MERGE_LIST.equals(filename)) {
+        return "Merge List";
+      } else {
+        return "File " + filename;
+      }
+    }
+  }
+
+  private List<Comment> inlineComments = Collections.emptyList();
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  public CommentSender(EmailArguments ea,
+      CommentsUtil commentsUtil,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id) throws OrmException {
+    super(ea, "comment", newChangeData(ea, project, id));
+    this.commentsUtil = commentsUtil;
+  }
+
+  public void setComments(List<Comment> comments) throws OrmException {
+    inlineComments = comments;
+
+    Set<String> paths = new HashSet<>();
+    for (Comment c : comments) {
+      if (!Patch.isMagic(c.key.filename)) {
+        paths.add(c.key.filename);
+      }
+    }
+    changeData.setCurrentFilePaths(Ordering.natural().sortedCopy(paths));
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    if (notify.compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
+      ccAllApprovals();
+    }
+    if (notify.compareTo(NotifyHandling.ALL) >= 0) {
+      bccStarredBy();
+      includeWatchers(NotifyType.ALL_COMMENTS);
+    }
+  }
+
+  @Override
+  public void formatChange() throws EmailException {
+    appendText(textTemplate("Comment"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("CommentHtml"));
+    }
+  }
+
+  @Override
+  public void formatFooter() throws EmailException {
+    appendText(textTemplate("CommentFooter"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("CommentFooterHtml"));
+    }
+  }
+
+  /**
+   * No longer used outside Velocity. Remove this method when VTL support is
+   * removed.
+   */
+  @Deprecated
+  public boolean hasInlineComments() {
+    return !inlineComments.isEmpty();
+  }
+
+  /**
+   * No longer used outside Velocity. Remove this method when VTL support is
+   * removed.
+   */
+  @Deprecated
+  public String getInlineComments() {
+    return getInlineComments(1);
+  }
+
+  /**
+   * No longer used outside Velocity. Remove this method when VTL support is
+   * removed.
+   */
+  @Deprecated
+  public String getInlineComments(int lines) {
+    StringBuilder cmts = new StringBuilder();
+    for (FileCommentGroup group : getGroupedInlineComments()) {
+      String link = group.getLink();
+      if (link != null) {
+        cmts.append(link).append('\n');
+      }
+      cmts.append(group.getTitle()).append(":\n\n");
+      for (Comment c : group.comments) {
+        appendComment(cmts, lines, group.fileData, c);
+      }
+      cmts.append("\n\n");
+    }
+    return cmts.toString();
+  }
+
+  /**
+   * @return a list of FileCommentGroup objects representing the inline comments
+   * grouped by the file.
+   */
+  private List<CommentSender.FileCommentGroup> getGroupedInlineComments() {
+    List<CommentSender.FileCommentGroup> groups = new ArrayList<>();
+    try (Repository repo = getRepository()) {
+      // Get the patch list:
+      PatchList patchList = null;
+      if (repo != null) {
+        try {
+          patchList = getPatchList();
+        } catch (PatchListNotAvailableException e) {
+          log.error("Failed to get patch list", e);
+        }
+      }
+
+      // Loop over the comments and collect them into groups based on the file
+      // location of the comment.
+      FileCommentGroup currentGroup = null;
+      for (Comment c : inlineComments) {
+        // If it's a new group:
+        if (currentGroup == null
+            || !c.key.filename.equals(currentGroup.filename)
+            || c.key.patchSetId != currentGroup.patchSetId) {
+          currentGroup = new FileCommentGroup();
+          currentGroup.filename = c.key.filename;
+          currentGroup.patchSetId = c.key.patchSetId;
+          groups.add(currentGroup);
+          if (patchList != null) {
+            try {
+              currentGroup.fileData =
+                  new PatchFile(repo, patchList, c.key.filename);
+            } catch (IOException e) {
+              log.warn(String.format(
+                  "Cannot load %s from %s in %s",
+                  c.key.filename,
+                  patchList.getNewId().name(),
+                  projectState.getProject().getName()), e);
+              currentGroup.fileData = null;
+            }
+          }
+        }
+
+        if (currentGroup.fileData != null) {
+          currentGroup.comments.add(c);
+        }
+      }
+    }
+
+    Collections.sort(groups,
+        Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE));
+    return groups;
+  }
+
+  /**
+   * No longer used except for Velocity. Remove this method when VTL support is
+   * removed.
+   */
+  @Deprecated
+  private void appendComment(StringBuilder out, int contextLines,
+      PatchFile currentFileData, Comment comment) {
+    if (comment instanceof RobotComment) {
+      RobotComment robotComment = (RobotComment) comment;
+      out.append("Robot Comment from ")
+         .append(robotComment.robotId)
+         .append(" (run ID ")
+         .append(robotComment.robotRunId)
+         .append("):\n");
+    }
+    if (comment.range != null) {
+      appendRangedComment(out, currentFileData, comment);
+    } else {
+      appendLineComment(out, contextLines, currentFileData, comment);
+    }
+  }
+
+  /**
+   * No longer used except for Velocity. Remove this method when VTL support is
+   * removed.
+   */
+  @Deprecated
+  private void appendRangedComment(StringBuilder out, PatchFile fileData,
+      Comment comment) {
+    String prefix = getCommentLinePrefix(comment);
+    String emptyPrefix = Strings.padStart(": ", prefix.length(), ' ');
+    boolean firstLine = true;
+    for (String line : getLinesByRange(comment.range, fileData, comment.side)) {
+      out.append(firstLine ? prefix : emptyPrefix)
+          .append(line)
+          .append('\n');
+      firstLine = false;
+    }
+    appendQuotedParent(out, comment);
+    out.append(comment.message.trim()).append('\n');
+  }
+
+  private String getCommentLinePrefix(Comment comment) {
+    int lineNbr = comment.range == null ?
+        comment.lineNbr : comment.range.startLine;
+    StringBuilder sb = new StringBuilder();
+    sb.append("PS").append(comment.key.patchSetId);
+    if (lineNbr != 0) {
+      sb.append(", Line ").append(lineNbr);
+    }
+    sb.append(": ");
+    return sb.toString();
+  }
+
+  /**
+   * @return the lines of file content in fileData that are encompassed by range
+   * on the given side.
+   */
+  private List<String> getLinesByRange(Comment.Range range,
+      PatchFile fileData, short side) {
+    List<String> lines = new ArrayList<>();
+
+    for (int n = range.startLine; n <= range.endLine; n++) {
+      String s = getLine(fileData, side, n);
+      if (n == range.startLine && n == range.endLine) {
+        s = s.substring(
+            Math.min(range.startChar, s.length()),
+            Math.min(range.endChar, s.length()));
+      } else if (n == range.startLine) {
+        s = s.substring(Math.min(range.startChar, s.length()));
+      } else if (n == range.endLine) {
+        s = s.substring(0, Math.min(range.endChar, s.length()));
+      }
+      lines.add(s);
+    }
+    return lines;
+  }
+
+  /**
+   * No longer used except for Velocity. Remove this method when VTL support is
+   * removed.
+   */
+  @Deprecated
+  private void appendLineComment(StringBuilder out, int contextLines,
+      PatchFile currentFileData, Comment comment) {
+    short side = comment.side;
+    int lineNbr = comment.lineNbr;
+
+    // Initialize maxLines to the known line number.
+    int maxLines = lineNbr;
+
+    try {
+      maxLines = currentFileData.getLineCount(side);
+    } catch (IOException err) {
+      // The file could not be read, leave the max as is.
+      log.warn(String.format("Failed to read file %s on side %d",
+          comment.key.filename, side), err);
+    } catch (NoSuchEntityException err) {
+      // The file could not be read, leave the max as is.
+      log.warn(String.format("Side %d of file %s didn't exist",
+           side, comment.key.filename), err);
+    }
+
+    int startLine = Math.max(1, lineNbr - contextLines + 1);
+    int stopLine = Math.min(maxLines, lineNbr + contextLines);
+
+    for (int line = startLine; line <= lineNbr; ++line) {
+      appendFileLine(out, currentFileData, side, line);
+    }
+    appendQuotedParent(out, comment);
+    out.append(comment.message.trim()).append('\n');
+
+    for (int line = lineNbr + 1; line < stopLine; ++line) {
+      appendFileLine(out, currentFileData, side, line);
+    }
+  }
+
+  /**
+   * No longer used except for Velocity. Remove this method when VTL support is
+   * removed.
+   */
+  @Deprecated
+  private void appendFileLine(StringBuilder cmts, PatchFile fileData,
+      short side, int line) {
+    String lineStr = getLine(fileData, side, line);
+    cmts.append("Line ")
+        .append(line)
+        .append(": ")
+        .append(lineStr)
+        .append("\n");
+  }
+
+  /**
+   * No longer used except for Velocity. Remove this method when VTL support is
+   * removed.
+   */
+  @Deprecated
+  private void appendQuotedParent(StringBuilder out, Comment child) {
+    Optional<Comment> parent = getParent(child);
+    if (parent.isPresent()) {
+      out.append("> ")
+          .append(getShortenedCommentMessage(parent.get()))
+          .append('\n');
+    }
+  }
+
+  /**
+   * Get the parent comment of a given comment.
+   * @param child the comment with a potential parent comment.
+   * @return an optional comment that will be  present if the given comment has
+   * a parent, and is empty if it does not.
+   */
+  private Optional<Comment> getParent(Comment child) {
+    if (child.parentUuid == null) {
+      return Optional.empty();
+    }
+
+    Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename,
+          child.key.patchSetId);
+    try {
+      return commentsUtil.get(args.db.get(), changeData.notes(), key);
+    } catch (OrmException e) {
+      log.warn("Could not find the parent of this comment: "
+          + child.toString());
+      return Optional.empty();
+    }
+  }
+
+  /**
+   * Retrieve the file lines refered to by a comment.
+   * @param comment The comment that refers to some file contents. The comment
+   *     may be a line comment or a ranged comment.
+   * @param fileData The file on which the comment appears.
+   * @return file contents referred to by the comment. If the comment is a line
+   *     comment, the result will be a list of one string. Otherwise it will be
+   *     a list of one or more strings.
+   */
+  private List<String> getLinesOfComment(Comment comment, PatchFile fileData) {
+    List<String> lines = new ArrayList<>();
+    if (comment.range == null) {
+      lines.add(getLine(fileData, comment.side, comment.lineNbr));
+    } else {
+      lines.addAll(getLinesByRange(comment.range, fileData, comment.side));
+    }
+    return lines;
+  }
+
+  /**
+   * @return a shortened version of the given comment's message. Will be
+   * shortened to 75 characters or the first line, whichever is shorter.
+   */
+  private String getShortenedCommentMessage(Comment comment) {
+    String msg = comment.message.trim();
+    if (msg.length() > 75) {
+      msg = msg.substring(0, 75);
+    }
+    int lf = msg.indexOf('\n');
+    if (lf > 0) {
+      msg = msg.substring(0, lf);
+    }
+    return msg;
+  }
+
+  /**
+   * @return grouped inline comment data mapped to data structures that are
+   * suitable for passing into Soy.
+   */
+  private List<Map<String, Object>> getCommentGroupsTemplateData() {
+    List<Map<String, Object>> commentGroups = new ArrayList<>();
+
+    for (CommentSender.FileCommentGroup group : getGroupedInlineComments()) {
+      Map<String, Object> groupData = new HashMap<>();
+      groupData.put("link", group.getLink());
+      groupData.put("title", group.getTitle());
+      groupData.put("patchSetId", group.patchSetId);
+
+      List<Map<String, Object>> commentsList = new ArrayList<>();
+      for (Comment comment : group.comments) {
+        Map<String, Object> commentData = new HashMap<>();
+        commentData.put("lines", getLinesOfComment(comment, group.fileData));
+        commentData.put("message", comment.message.trim());
+
+        // Set the prefix.
+        String prefix = getCommentLinePrefix(comment);
+        commentData.put("linePrefix", prefix);
+        commentData.put("linePrefixEmpty",
+            Strings.padStart(": ", prefix.length(), ' '));
+
+        // Set line numbers.
+        int startLine;
+        if (comment.range == null) {
+          startLine = comment.lineNbr;
+        } else {
+          startLine = comment.range.startLine;
+          commentData.put("endLine", comment.range.endLine);
+        }
+        commentData.put("startLine", startLine);
+
+        // Set the comment link.
+        if (comment.lineNbr == 0) {
+          commentData.put("link", group.getLink());
+        } else if (comment.side == 0) {
+          commentData.put("link", group.getLink() + "@a" + startLine);
+        } else {
+          commentData.put("link", group.getLink() + '@' + startLine);
+        }
+
+        // Set robot comment data.
+        if (comment instanceof RobotComment) {
+          RobotComment robotComment = (RobotComment) comment;
+          commentData.put("isRobotComment", true);
+          commentData.put("robotId", robotComment.robotId);
+          commentData.put("robotRunId", robotComment.robotRunId);
+          commentData.put("robotUrl", robotComment.url);
+        } else {
+          commentData.put("isRobotComment", false);
+        }
+
+        // Set parent comment info.
+        Optional<Comment> parent = getParent(comment);
+        if (parent.isPresent()) {
+          commentData.put("parentMessage",
+              getShortenedCommentMessage(parent.get()));
+        }
+
+        commentsList.add(commentData);
+      }
+      groupData.put("comments", commentsList);
+
+      commentGroups.add(groupData);
+    }
+    return commentGroups;
+  }
+
+  private Repository getRepository() {
+    try {
+      return args.server.openRepository(projectState.getProject().getNameKey());
+    } catch (IOException e) {
+      return null;
+    }
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContext.put("commentFiles", getCommentGroupsTemplateData());
+  }
+
+  private String getLine(PatchFile fileInfo, short side, int lineNbr) {
+    try {
+      return fileInfo.getLine(side, lineNbr);
+    } catch (IOException err) {
+      // Default to the empty string if the file cannot be safely read.
+      log.warn(String.format("Failed to read file on side %d", side), err);
+      return "";
+    } catch (IndexOutOfBoundsException err) {
+      // Default to the empty string if the given line number does not appear
+      // in the file.
+      log.warn(String.format("Failed to get line number of file on side %d",
+          side), err);
+      return "";
+    } catch (NoSuchEntityException err) {
+      // Default to the empty string if the side cannot be found.
+      log.warn(String.format("Side %d of file didn't exist", side), err);
+      return "";
+    }
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CreateChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
similarity index 94%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/CreateChangeSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index 2110e37..98a03e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CreateChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.errors.EmailException;
@@ -20,7 +20,8 @@
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.mail.ProjectWatch.Watchers;
+import com.google.gerrit.server.mail.RecipientType;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
similarity index 84%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteReviewerSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index 75f9f82..28fb714 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteReviewerSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.mail.RecipientType;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -65,7 +66,10 @@
 
   @Override
   protected void formatChange() throws EmailException {
-    appendText(velocifyFile("DeleteReviewer.vm"));
+    appendText(textTemplate("DeleteReviewer"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("DeleteReviewerHtml"));
+    }
   }
 
   public List<String> getReviewerNames() {
@@ -78,4 +82,15 @@
     }
     return names;
   }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("reviewerNames", getReviewerNames());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteVoteSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
similarity index 87%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteVoteSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
index d861109..f8d1745 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteVoteSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
@@ -49,6 +49,14 @@
 
   @Override
   protected void formatChange() throws EmailException {
-    appendText(velocifyFile("DeleteVote.vm"));
+    appendText(textTemplate("DeleteVote"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("DeleteVoteHtml"));
+    }
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailArguments.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
similarity index 93%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailArguments.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 68e5e50..818023f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailArguments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2010 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -30,8 +30,10 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -43,6 +45,7 @@
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.template.soy.tofu.SoyTofu;
 
 import org.apache.velocity.runtime.RuntimeInstance;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -69,11 +72,13 @@
   final Provider<String> urlProvider;
   final AllProjectsName allProjectsName;
   final List<String> sshAddresses;
+  final SitePaths site;
 
   final ChangeQueryBuilder queryBuilder;
   final Provider<ReviewDb> db;
   final ChangeData.Factory changeDataFactory;
   final RuntimeInstance velocityRuntime;
+  final SoyTofu soyTofu;
   final EmailSettings settings;
   final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
   final StarredChangesUtil starredChangesUtil;
@@ -100,8 +105,10 @@
       Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       RuntimeInstance velocityRuntime,
+      @MailTemplates SoyTofu soyTofu,
       EmailSettings settings,
       @SshAdvertisedAddresses List<String> sshAddresses,
+      SitePaths site,
       DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners,
       StarredChangesUtil starredChangesUtil,
       AccountIndexCollection accountIndexes,
@@ -128,8 +135,10 @@
     this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.velocityRuntime = velocityRuntime;
+    this.soyTofu = soyTofu;
     this.settings = settings;
     this.sshAddresses = sshAddresses;
+    this.site = site;
     this.outgoingEmailValidationListeners = outgoingEmailValidationListeners;
     this.starredChangesUtil = starredChangesUtil;
     this.accountIndexes = accountIndexes;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailHeader.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java
similarity index 95%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailHeader.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java
index 6a964a3..43d365c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailHeader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.server.mail.Address;
 
 import java.io.IOException;
 import java.io.Writer;
@@ -75,7 +76,7 @@
     }
   }
 
-  static boolean needsQuotedPrintable(java.lang.String value) {
+  public static boolean needsQuotedPrintable(java.lang.String value) {
     for (int i = 0; i < value.length(); i++) {
       if (value.charAt(i) < ' ' || '~' < value.charAt(i)) {
         return true;
@@ -104,7 +105,7 @@
     }
   }
 
-  static java.lang.String quotedPrintable(java.lang.String value) {
+  public static java.lang.String quotedPrintable(java.lang.String value) {
     final StringBuilder r = new StringBuilder();
 
     r.append("=?UTF-8?Q?");
@@ -191,7 +192,7 @@
 
     void remove(java.lang.String email) {
       for (Iterator<Address> i = list.iterator(); i.hasNext();) {
-        if (i.next().email.equals(email)) {
+        if (i.next().getEmail().equals(email)) {
           i.remove();
         }
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailSender.java
new file mode 100644
index 0000000..0bfb6f2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailSender.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.server.mail.Address;
+
+import java.util.Collection;
+import java.util.Map;
+
+/** Sends email messages to third parties. */
+public interface EmailSender {
+  boolean isEnabled();
+
+  /**
+   * Can the address receive messages from us?
+   *
+   * @param address the address to consider.
+   * @return true if this sender will deliver to the address.
+   */
+  boolean canEmail(String address);
+
+  /**
+   * Sends an email message. Messages always contain a text body, but messages
+   * can optionally include an additional HTML body. If both body types are
+   * present, {@code send} should construct a {@code multipart/alternative}
+   * message with an appropriately-selected boundary.
+   *
+   * @param from who the message is from.
+   * @param rcpt one or more address where the message will be delivered to.
+   *        This list overrides any To or CC headers in {@code headers}.
+   * @param headers message headers.
+   * @param textBody text to appear in the {@code text/plain} body of the
+   *        message.
+   * @param htmlBody optional HTML code to appear in the {@code text/html} body
+   *        of the message.
+   * @throws EmailException the message cannot be sent.
+   */
+  default void send(Address from, Collection<Address> rcpt,
+      Map<String, EmailHeader> headers, String textBody,
+      @Nullable String htmlBody) throws EmailException {
+    send(from, rcpt, headers, textBody);
+  }
+
+  /**
+   * Sends an email message with a text body only (i.e. not HTML or multipart).
+   *
+   * Authors of new implementations of this interface should not use this method
+   * to send a message because this method does not accept the HTML body.
+   * Instead, authors should use the above signature of {@code send}.
+   *
+   * This version of the method is preserved for support of legacy
+   * implementations.
+   *
+   * @param from who the message is from.
+   * @param rcpt one or more address where the message will be delivered to.
+   *        This list overrides any To or CC headers in {@code headers}.
+   * @param headers message headers.
+   * @param body text to appear in the body of the message.
+   * @throws EmailException the message cannot be sent.
+   */
+  void send(Address from, Collection<Address> rcpt,
+      Map<String, EmailHeader> headers, String body) throws EmailException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
similarity index 90%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
index 9bcabc3..2489063 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
@@ -12,9 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.mail.Address;
 
 /** Constructs an address to send email from. */
 public interface FromAddressGenerator {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGeneratorProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
similarity index 64%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGeneratorProvider.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
index 51f7ad1..3326b38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGeneratorProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -22,6 +22,8 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.MailUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -32,6 +34,7 @@
 
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
+import java.util.regex.Pattern;
 
 /** Creates a {@link FromAddressGenerator} from the {@link GerritServerConfig} */
 @Singleton
@@ -52,23 +55,26 @@
       ParameterizedString name = new ParameterizedString("${user} (Code Review)");
       generator =
           new PatternGen(srvAddr, accountCache, anonymousCowardName, name,
-              srvAddr.email);
-
+              srvAddr.getEmail());
     } else if ("USER".equalsIgnoreCase(from)) {
-      generator = new UserGen(accountCache, srvAddr);
-
+      String[] domains = cfg.getStringList("sendemail", null, "allowedDomain");
+      Pattern domainPattern = MailUtil.glob(domains);
+      ParameterizedString namePattern =
+          new ParameterizedString("${user} (Code Review)");
+      generator = new UserGen(accountCache, domainPattern, anonymousCowardName,
+          namePattern, srvAddr);
     } else if ("SERVER".equalsIgnoreCase(from)) {
       generator = new ServerGen(srvAddr);
-
     } else {
       final Address a = Address.parse(from);
-      final ParameterizedString name = a.name != null ? new ParameterizedString(a.name) : null;
+      final ParameterizedString name =
+          a.getName() != null ? new ParameterizedString(a.getName()) : null;
       if (name == null || name.getParameterNames().isEmpty()) {
         generator = new ServerGen(a);
       } else {
         generator =
             new PatternGen(srvAddr, accountCache, anonymousCowardName, name,
-                a.email);
+                a.getEmail());
       }
     }
   }
@@ -84,11 +90,31 @@
 
   static final class UserGen implements FromAddressGenerator {
     private final AccountCache accountCache;
-    private final Address srvAddr;
+    private final Pattern domainPattern;
+    private final String anonymousCowardName;
+    private final ParameterizedString nameRewriteTmpl;
+    private final Address serverAddress;
 
-    UserGen(AccountCache accountCache, Address srvAddr) {
+    /**
+     * From address generator for USER mode
+     *
+     * @param accountCache get user account from id
+     * @param domainPattern allowed user domain pattern that Gerrit can send as
+     *        the user
+     * @param anonymousCowardName name used when user's full name is missing
+     * @param nameRewriteTmpl name template used for rewriting the sender's name
+     *        when Gerrit can not send as the user
+     * @param serverAddress serverAddress.name is used when fromId is null and
+     *        serverAddress.email is used when Gerrit can not send as the user
+     */
+    UserGen(AccountCache accountCache, Pattern domainPattern,
+        String anonymousCowardName, ParameterizedString nameRewriteTmpl,
+        Address serverAddress) {
       this.accountCache = accountCache;
-      this.srvAddr = srvAddr;
+      this.domainPattern = domainPattern;
+      this.anonymousCowardName = anonymousCowardName;
+      this.nameRewriteTmpl = nameRewriteTmpl;
+      this.serverAddress = serverAddress;
     }
 
     @Override
@@ -98,14 +124,44 @@
 
     @Override
     public Address from(final Account.Id fromId) {
+      String senderName;
       if (fromId != null) {
         Account a = accountCache.get(fromId).getAccount();
+        String fullName = a.getFullName();
         String userEmail = a.getPreferredEmail();
-        return new Address(
-            a.getFullName(),
-            userEmail != null ? userEmail : srvAddr.getEmail());
+        if (canRelay(userEmail)) {
+          return new Address(fullName, userEmail);
+        }
+
+        if (fullName == null || "".equals(fullName.trim())) {
+          fullName = anonymousCowardName;
+        }
+        senderName = nameRewriteTmpl.replace("user", fullName).toString();
+      } else {
+        senderName = serverAddress.getName();
       }
-      return srvAddr;
+
+      String senderEmail;
+      ParameterizedString senderEmailPattern =
+          new ParameterizedString(serverAddress.getEmail());
+      if (senderEmailPattern.getParameterNames().isEmpty()) {
+        senderEmail = senderEmailPattern.getRawPattern();
+      } else {
+        senderEmail = senderEmailPattern.replace("userHash", hashOf(senderName))
+            .toString();
+      }
+      return new Address(senderName, senderEmail);
+    }
+
+    /** check if Gerrit is allowed to send from {@code userEmail}. */
+    private boolean canRelay(String userEmail) {
+      if (userEmail != null) {
+        int index = userEmail.indexOf('@');
+        if (index > 0 && index < userEmail.length() - 1) {
+          return domainPattern.matcher(userEmail.substring(index + 1)).matches();
+        }
+      }
+      return false;
     }
   }
 
@@ -162,7 +218,7 @@
         senderName = namePattern.replace("user", fullName).toString();
 
       } else {
-        senderName = serverAddress.name;
+        senderName = serverAddress.getName();
       }
 
       String senderEmail;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
new file mode 100644
index 0000000..9f95def
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.common.io.CharStreams;
+import com.google.common.io.Resources;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.tofu.SoyTofu;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/** Configures Soy Tofu object for rendering email templates. */
+@Singleton
+public class MailSoyTofuProvider implements Provider<SoyTofu> {
+
+  // Note: will fail to construct the tofu object if this array is empty.
+  private static final String[] TEMPLATES = {
+    "Abandoned.soy",
+    "AbandonedHtml.soy",
+    "AddKey.soy",
+    "AddKeyHtml.soy",
+    "ChangeFooter.soy",
+    "ChangeFooterHtml.soy",
+    "ChangeSubject.soy",
+    "Comment.soy",
+    "CommentHtml.soy",
+    "CommentFooter.soy",
+    "CommentFooterHtml.soy",
+    "DeleteReviewer.soy",
+    "DeleteReviewerHtml.soy",
+    "DeleteVote.soy",
+    "DeleteVoteHtml.soy",
+    "Footer.soy",
+    "FooterHtml.soy",
+    "HeaderHtml.soy",
+    "Merged.soy",
+    "MergedHtml.soy",
+    "NewChange.soy",
+    "NewChangeHtml.soy",
+    "Private.soy",
+    "RegisterNewEmail.soy",
+    "ReplacePatchSet.soy",
+    "ReplacePatchSetHtml.soy",
+    "Restored.soy",
+    "RestoredHtml.soy",
+    "Reverted.soy",
+    "RevertedHtml.soy",
+  };
+
+  private final SitePaths site;
+
+  @Inject
+  MailSoyTofuProvider(SitePaths site) {
+    this.site = site;
+  }
+
+  @Override
+  public SoyTofu get() throws ProvisionException {
+    SoyFileSet.Builder builder = SoyFileSet.builder();
+    for (String name : TEMPLATES) {
+      addTemplate(builder, name);
+    }
+    return builder.build().compileToTofu();
+  }
+
+  private void addTemplate(SoyFileSet.Builder builder, String name)
+      throws ProvisionException {
+    // Load as a file in the mail templates directory if present.
+    Path tmpl = site.mail_dir.resolve(name);
+    if (Files.isRegularFile(tmpl)) {
+      String content;
+      try (Reader r = Files.newBufferedReader(tmpl, StandardCharsets.UTF_8)) {
+        content = CharStreams.toString(r);
+      } catch (IOException err) {
+        throw new ProvisionException("Failed to read template file " +
+            tmpl.toAbsolutePath().toString(), err);
+      }
+      builder.add(content, tmpl.toAbsolutePath().toString());
+      return;
+    }
+
+    // Otherwise load the template as a resource.
+    String resourcePath = "com/google/gerrit/server/mail/" + name;
+    builder.add(Resources.getResource(resourcePath));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailTemplates.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailTemplates.java
index 9bcabc3..b92567f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailTemplates.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
-import com.google.gerrit.reviewdb.client.Account;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
+import com.google.inject.BindingAnnotation;
 
-  Address from(Account.Id fromId);
-}
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface MailTemplates {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
similarity index 91%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
index f6c3d0f..a5c8940 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.Table;
@@ -58,7 +58,11 @@
 
   @Override
   protected void formatChange() throws EmailException {
-    appendText(velocifyFile("Merged.vm"));
+    appendText(textTemplate("Merged"));
+
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("MergedHtml"));
+    }
   }
 
   public String getApprovals() {
@@ -123,4 +127,15 @@
     txt.append('\n');
     return txt.toString();
   }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("approvals", getApprovals());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/NewChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
similarity index 83%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/NewChangeSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 62385d9..49a909a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/NewChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -12,10 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.mail.RecipientType;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 
@@ -67,7 +68,10 @@
 
   @Override
   protected void formatChange() throws EmailException {
-    appendText(velocifyFile("NewChange.vm"));
+    appendText(textTemplate("NewChange"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("NewChangeHtml"));
+    }
   }
 
   public List<String> getReviewerNames() {
@@ -80,4 +84,15 @@
     }
     return names;
   }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("reviewerNames", getReviewerNames());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/NotificationEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
similarity index 80%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/NotificationEmail.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index de338ec..baf0d93 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/NotificationEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -12,19 +12,24 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.mail.ProjectWatch.Watchers;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.RecipientType;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gwtorm.server.OrmException;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.HashMap;
+import java.util.Map;
+
 /**
  * Common class for notifications that are related to a project and branch
  */
@@ -103,4 +108,20 @@
     velocityContext.put("projectName", branch.getParentKey().get());
     velocityContext.put("branch", branch);
   }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+
+    String projectName = branch.getParentKey().get();
+    soyContext.put("projectName", projectName);
+    // shortProjectName is the project name with the path abbreviated.
+    soyContext.put("shortProjectName", projectName.replaceAll("/.*/", "..."));
+
+    soyContextEmailData.put("sshHost", getSshHost());
+
+    Map<String, String> branchData = new HashMap<>();
+    branchData.put("shortName", branch.getShortName());
+    soyContext.put("branch", branchData);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
similarity index 81%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 6200688..1051b9d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
@@ -24,10 +24,13 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.mail.EmailHeader.AddressList;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.RecipientType;
+import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
+import com.google.template.soy.data.SanitizedContent;
 
 import org.apache.commons.lang.StringUtils;
 import org.apache.velocity.Template;
@@ -43,8 +46,11 @@
 import java.io.StringWriter;
 import java.net.MalformedURLException;
 import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.Collection;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
@@ -63,9 +69,11 @@
   private final Map<String, EmailHeader> headers;
   private final Set<Address> smtpRcptTo = new HashSet<>();
   private Address smtpFromAddress;
-  private StringBuilder body;
+  private StringBuilder textBody;
+  private StringBuilder htmlBody;
   protected VelocityContext velocityContext;
-
+  protected Map<String, Object> soyContext;
+  protected Map<String, Object> soyContextEmailData;
   protected final EmailArguments args;
   protected Account.Id fromId;
   protected NotifyHandling notify = NotifyHandling.ALL;
@@ -101,8 +109,14 @@
     }
 
     init();
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("HeaderHtml"));
+    }
     format();
-    appendText(velocifyFile("Footer.vm"));
+    appendText(textTemplate("Footer"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("FooterHtml"));
+    }
     if (shouldSendMessage()) {
       if (fromId != null) {
         final Account fromUser = args.accountCache.get(fromId).getAccount();
@@ -136,12 +150,20 @@
         }
       }
 
+      String textPart = textBody.toString();
       OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args();
       va.messageClass = messageClass;
       va.smtpFromAddress = smtpFromAddress;
       va.smtpRcptTo = smtpRcptTo;
       va.headers = headers;
-      va.body = body.toString();
+
+      va.body = textPart;
+      if (useHtml()) {
+        va.htmlBody = htmlBody.toString();
+      } else {
+        va.htmlBody = null;
+      }
+
       for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
         try {
           validator.validateOutgoingEmail(va);
@@ -150,7 +172,8 @@
         }
       }
 
-      args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body);
+      args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers,
+          va.body, va.htmlBody);
     }
   }
 
@@ -164,6 +187,7 @@
    */
   protected void init() throws EmailException {
     setupVelocityContext();
+    setupSoyContext();
 
     smtpFromAddress = args.fromAddressGenerator.from(fromId);
     setHeader("Date", new Date());
@@ -179,13 +203,14 @@
       // Reply-To header with the current user's email address.
       //
       final Address a = toAddress(fromId);
-      if (a != null && !smtpFromAddress.email.equals(a.email)) {
-        setHeader("Reply-To", a.email);
+      if (a != null && !smtpFromAddress.getEmail().equals(a.getEmail())) {
+        setHeader("Reply-To", a.getEmail());
       }
     }
 
     setHeader("X-Gerrit-MessageType", messageClass);
-    body = new StringBuilder();
+    textBody = new StringBuilder();
+    htmlBody = new StringBuilder();
 
     if (fromId != null && args.fromAddressGenerator.isGenericAddress(fromId)) {
       appendText(getFromLine());
@@ -260,7 +285,14 @@
   /** Append text to the outgoing email body. */
   protected void appendText(final String text) {
     if (text != null) {
-      body.append(text);
+      textBody.append(text);
+    }
+  }
+
+  /** Append html to the outgoing email body. */
+  protected void appendHtml(String html) {
+    if (html != null) {
+      htmlBody.append(html);
     }
   }
 
@@ -334,7 +366,7 @@
   }
 
   protected boolean shouldSendMessage() {
-    if (body.length() == 0) {
+    if (textBody.length() == 0) {
       // If we have no message body, don't send.
       return false;
     }
@@ -391,11 +423,11 @@
 
   /** Schedule delivery of this message to the given account. */
   protected void add(final RecipientType rt, final Address addr) {
-    if (addr != null && addr.email != null && addr.email.length() > 0) {
-      if (!OutgoingEmailValidator.isValid(addr.email)) {
-        log.warn("Not emailing " + addr.email + " (invalid email address)");
-      } else if (!args.emailSender.canEmail(addr.email)) {
-        log.warn("Not emailing " + addr.email + " (prohibited by allowrcpt)");
+    if (addr != null && addr.getEmail() != null && addr.getEmail().length() > 0) {
+      if (!OutgoingEmailValidator.isValid(addr.getEmail())) {
+        log.warn("Not emailing " + addr.getEmail() + " (invalid email address)");
+      } else if (!args.emailSender.canEmail(addr.getEmail())) {
+        log.warn("Not emailing " + addr.getEmail() + " (prohibited by allowrcpt)");
       } else if (smtpRcptTo.add(addr)) {
         switch (rt) {
           case TO:
@@ -428,6 +460,18 @@
     velocityContext.put("StringUtils", StringUtils.class);
   }
 
+  protected void setupSoyContext() {
+    soyContext = new HashMap<>();
+
+    soyContext.put("messageClass", messageClass);
+
+    soyContextEmailData = new HashMap<>();
+    soyContextEmailData.put("settingsUrl", getSettingsUrl());
+    soyContextEmailData.put("gerritHost", getGerritHost());
+    soyContextEmailData.put("gerritUrl", getGerritUrl());
+    soyContext.put("email", soyContextEmailData);
+  }
+
   protected String velocify(String template) throws EmailException {
     try {
       RuntimeInstance runtime = args.velocityRuntime;
@@ -463,6 +507,37 @@
     }
   }
 
+  private String soyTemplate(String name, SanitizedContent.ContentKind kind) {
+    return args.soyTofu
+        .newRenderer("com.google.gerrit.server.mail.template." + name)
+        .setContentKind(kind)
+        .setData(soyContext)
+        .render();
+  }
+
+  protected String soyTextTemplate(String name) {
+    return soyTemplate(name, SanitizedContent.ContentKind.TEXT);
+  }
+
+  protected String soyHtmlTemplate(String name) {
+    return soyTemplate(name, SanitizedContent.ContentKind.HTML);
+  }
+
+  /**
+   * Evaluate the named template according to the following priority:
+   * 1) Velocity file override, OR...
+   * 2) Soy file override, OR...
+   * 3) Soy resource.
+   */
+  protected String textTemplate(String name) throws EmailException {
+    String velocityName = name + ".vm";
+    Path filePath = args.site.mail_dir.resolve(velocityName);
+    if (Files.isRegularFile(filePath)) {
+      return velocifyFile(velocityName);
+    }
+    return soyTextTemplate(name);
+  }
+
   public String joinStrings(Iterable<Object> in, String joiner) {
     return joinStrings(in.iterator(), joiner);
   }
@@ -488,7 +563,7 @@
   protected void removeUser(Account user) {
     String fromEmail = user.getPreferredEmail();
     for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext();) {
-      if (j.next().email.equals(fromEmail)) {
+      if (j.next().getEmail().equals(fromEmail)) {
         j.remove();
       }
     }
@@ -504,4 +579,13 @@
   private static String safeToString(Object obj) {
     return obj != null ? obj.toString() : "";
   }
+
+  protected final boolean useHtml() {
+    return args.settings.html && supportsHtml();
+  }
+
+  /** Override this method to enable HTML in a subclass. */
+  protected boolean supportsHtml() {
+    return false;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmailValidator.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
similarity index 95%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmailValidator.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
index 5ab5f4e..1e92c83 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmailValidator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import static org.apache.commons.validator.routines.DomainValidator.ArrayType.GENERIC_PLUS;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
similarity index 98%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index f19b2a8..3a43691 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.git.NotifyConfig;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RegisterNewEmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
similarity index 80%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/RegisterNewEmailSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
index cfdeb8f..03e9b7e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RegisterNewEmailSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
@@ -12,12 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.EmailTokenVerifier;
+import com.google.gerrit.server.mail.RecipientType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -51,7 +54,7 @@
 
   @Override
   protected void format() throws EmailException {
-    appendText(velocifyFile("RegisterNewEmail.vm"));
+    appendText(textTemplate("RegisterNewEmail"));
   }
 
   public String getUserNameEmail() {
@@ -69,4 +72,12 @@
   public boolean isAllowed() {
     return args.emailSender.canEmail(addr);
   }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData
+        .put("emailRegistrationToken", getEmailRegistrationToken());
+    soyContextEmailData.put("userNameEmail", getUserNameEmail());
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplacePatchSetSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
similarity index 82%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplacePatchSetSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index df9f20e..8fa8c57 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplacePatchSetSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.mail.RecipientType;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -72,17 +73,34 @@
 
   @Override
   protected void formatChange() throws EmailException {
-    appendText(velocifyFile("ReplacePatchSet.vm"));
+    appendText(textTemplate("ReplacePatchSet"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("ReplacePatchSetHtml"));
+    }
   }
 
   public List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
-      return null;
-    }
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
+      if (id.equals(fromId)) {
+        continue;
+      }
       names.add(getNameFor(id));
     }
+    if (names.isEmpty()) {
+      return null;
+    }
     return names;
   }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("reviewerNames", getReviewerNames());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplyToChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
similarity index 93%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplyToChangeSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
index dd922d3..02d3f32 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplyToChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.mail.RecipientType;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RestoredSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RestoredSender.java
similarity index 84%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/RestoredSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RestoredSender.java
index d946eb2..1b1823e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RestoredSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RestoredSender.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
@@ -35,7 +35,7 @@
       @Assisted Project.NameKey project,
       @Assisted Change.Id id)
       throws OrmException {
-    super(ea, "restore", newChangeData(ea, project, id));
+    super(ea, "restore", ChangeEmail.newChangeData(ea, project, id));
   }
 
   @Override
@@ -49,6 +49,14 @@
 
   @Override
   protected void formatChange() throws EmailException {
-    appendText(velocifyFile("Restored.vm"));
+    appendText(textTemplate("Restored"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("RestoredHtml"));
+    }
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RevertedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RevertedSender.java
similarity index 83%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/RevertedSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RevertedSender.java
index 2c9c37e..b297ee1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RevertedSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RevertedSender.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
@@ -33,7 +33,7 @@
       @Assisted Project.NameKey project,
       @Assisted Change.Id id)
       throws OrmException {
-    super(ea, "revert", newChangeData(ea, project, id));
+    super(ea, "revert", ChangeEmail.newChangeData(ea, project, id));
   }
 
   @Override
@@ -47,6 +47,14 @@
 
   @Override
   protected void formatChange() throws EmailException {
-    appendText(velocifyFile("Reverted.vm"));
+    appendText(textTemplate("Reverted"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("RevertedHtml"));
+    }
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SmtpEmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
similarity index 77%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/SmtpEmailSender.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index e263c6a..f27c45f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SmtpEmailSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -12,16 +12,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.io.BaseEncoding;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.Address;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -42,6 +45,7 @@
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.TimeUnit;
 
 /** Sends email via a nearby SMTP server. */
@@ -146,8 +150,15 @@
 
   @Override
   public void send(final Address from, Collection<Address> rcpt,
-      final Map<String, EmailHeader> callerHeaders, final String body)
+      final Map<String, EmailHeader> callerHeaders, String body)
       throws EmailException {
+    send(from, rcpt, callerHeaders, body, null);
+  }
+
+  @Override
+  public void send(final Address from, Collection<Address> rcpt,
+      final Map<String, EmailHeader> callerHeaders, String textBody,
+      @Nullable String htmlBody) throws EmailException {
     if (!isEnabled()) {
       throw new EmailException("Sending email is disabled");
     }
@@ -155,7 +166,6 @@
     final Map<String, EmailHeader> hdrs =
         new LinkedHashMap<>(callerHeaders);
     setMissingHeader(hdrs, "MIME-Version", "1.0");
-    setMissingHeader(hdrs, "Content-Type", "text/plain; charset=UTF-8");
     setMissingHeader(hdrs, "Content-Transfer-Encoding", "8bit");
     setMissingHeader(hdrs, "Content-Disposition", "inline");
     setMissingHeader(hdrs, "User-Agent", "Gerrit/" + Version.getVersion());
@@ -169,13 +179,25 @@
         new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z").format(expiry));
     }
 
+    String encodedBody;
+    if (htmlBody == null) {
+      setMissingHeader(hdrs, "Content-Type", "text/plain; charset=UTF-8");
+      encodedBody = textBody;
+    } else {
+      String boundary = generateMultipartBoundary(textBody, htmlBody);
+      setMissingHeader(hdrs, "Content-Type", "multipart/alternative; "
+          + "boundary=\"" + boundary + "\"; "
+          + "charset=UTF-8");
+      encodedBody = buildMultipartBody(boundary, textBody, htmlBody);
+    }
+
     StringBuffer rejected = new StringBuffer();
     try {
       final SMTPClient client = open();
       try {
-        if (!client.setSender(from.email)) {
+        if (!client.setSender(from.getEmail())) {
           throw new EmailException("Server " + smtpHost
-              + " rejected from address " + from.email);
+              + " rejected from address " + from.getEmail());
         }
 
         /* Do not prevent the email from being sent to "good" users simply
@@ -186,7 +208,7 @@
          * error(s) logged.
          */
         for (Address addr : rcpt) {
-          if (!client.addRecipient(addr.email)) {
+          if (!client.addRecipient(addr.getEmail())) {
             String error = client.getReplyString();
             rejected.append("Server ").append(smtpHost)
                     .append(" rejected recipient ").append(addr)
@@ -214,7 +236,7 @@
           }
 
           w.write("\r\n");
-          w.write(body);
+          w.write(encodedBody);
           w.flush();
         }
 
@@ -235,6 +257,49 @@
     }
   }
 
+  public static String generateMultipartBoundary(String textBody,
+      String htmlBody) throws EmailException {
+    byte[] bytes = new byte[8];
+    ThreadLocalRandom rng = ThreadLocalRandom.current();
+
+    // The probability of the boundary being valid is approximately
+    // (2^64 - len(message)) / 2^64.
+    //
+    // The message is much shorter than 2^64 bytes, so if two tries don't
+    // suffice, something is seriously wrong.
+    for (int i = 0; i < 2; i++) {
+      rng.nextBytes(bytes);
+      String boundary = BaseEncoding.base64().encode(bytes);
+      String encBoundary = "--" + boundary;
+      if (textBody.contains(encBoundary) || htmlBody.contains(encBoundary)) {
+        continue;
+      }
+      return boundary;
+    }
+    throw new EmailException("Gave up generating unique MIME boundary");
+  }
+
+  protected String buildMultipartBody(String boundary, String textPart,
+      String htmlPart) {
+    return
+        // Output the text part:
+        "--" + boundary + "\r\n"
+        + "Content-Type: text/plain; charset=UTF-8\r\n"
+        + "Content-Transfer-Encoding: 8bit\r\n"
+        + "\r\n"
+        + textPart + "\r\n"
+
+        // Output the HTML part:
+        + "--" + boundary + "\r\n"
+        + "Content-Type: text/html; charset=UTF-8\r\n"
+        + "Content-Transfer-Encoding: 8bit\r\n"
+        + "\r\n"
+        + htmlPart + "\r\n"
+
+        // Output the closing boundary.
+        + "--" + boundary + "--\r\n";
+  }
+
   private void setMissingHeader(final Map<String, EmailHeader> hdrs,
       final String name, final String value) {
     if (!hdrs.containsKey(name) || hdrs.get(name).isEmpty()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/VelocityRuntimeProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/VelocityRuntimeProvider.java
similarity index 98%
rename from gerrit-server/src/main/java/com/google/gerrit/server/mail/VelocityRuntimeProvider.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/mail/send/VelocityRuntimeProvider.java
index 3fdc550..03d4f7a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/VelocityRuntimeProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/VelocityRuntimeProvider.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 679a9de..3c669f0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 70a5f4f..fa23b80 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
@@ -45,6 +46,7 @@
   protected final ChangeNoteUtil noteUtil;
   protected final String anonymousCowardName;
   protected final Account.Id accountId;
+  protected final Account.Id realAccountId;
   protected final PersonIdent authorIdent;
   protected final Date when;
 
@@ -69,6 +71,9 @@
     this.notes = ctl.getNotes();
     this.change = notes.getChange();
     this.accountId = accountId(ctl.getUser());
+    Account.Id realAccountId = accountId(ctl.getUser().getRealUser());
+    this.realAccountId =
+        realAccountId != null ? realAccountId : accountId;
     this.authorIdent =
         ident(noteUtil, serverIdent, anonymousCowardName, ctl.getUser(), when);
     this.when = when;
@@ -82,6 +87,7 @@
       @Nullable ChangeNotes notes,
       @Nullable Change change,
       Account.Id accountId,
+      Account.Id realAccountId,
       PersonIdent authorIdent,
       Date when) {
     checkArgument(
@@ -95,6 +101,7 @@
     this.notes = notes;
     this.change = change != null ? change : notes.getChange();
     this.accountId = accountId;
+    this.realAccountId = realAccountId;
     this.authorIdent = authorIdent;
     this.when = when;
   }
@@ -255,4 +262,18 @@
   private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
     return ins.insert(Constants.OBJ_TREE, new byte[] {});
   }
+
+  protected void verifyComment(Comment c) {
+    checkArgument(c.revId != null, "RevId required for comment: %s", c);
+    checkArgument(
+        c.author.getId().equals(getAccountId()),
+        "The author for the following comment does not match the author of"
+            + " this %s (%s): %s",
+        getClass().getSimpleName(), getAccountId(), c);
+    checkArgument(
+        c.getRealAuthor().getId().equals(realAccountId),
+        "The real author for the following comment does not match the real"
+            + " author of this %s (%s): %s",
+        getClass().getSimpleName(), realAccountId, c);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
index e15af9d..3c7277a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -50,9 +50,9 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.server.OrmException;
 
@@ -83,35 +83,18 @@
     REVIEW_DB, NOTE_DB;
   }
 
-  public static ChangeBundle fromReviewDb(ReviewDb db, Change.Id id)
-      throws OrmException {
-    db.changes().beginTransaction(id);
-    try {
-      List<PatchSetApproval> approvals =
-          db.patchSetApprovals().byChange(id).toList();
-      return new ChangeBundle(
-          db.changes().get(id),
-          db.changeMessages().byChange(id),
-          db.patchSets().byChange(id),
-          approvals,
-          db.patchComments().byChange(id),
-          ReviewerSet.fromApprovals(approvals),
-          Source.REVIEW_DB);
-    } finally {
-      db.rollback();
-    }
-  }
-
-  public static ChangeBundle fromNotes(PatchLineCommentsUtil plcUtil,
+  public static ChangeBundle fromNotes(CommentsUtil commentsUtil,
       ChangeNotes notes) throws OrmException {
     return new ChangeBundle(
         notes.getChange(),
         notes.getChangeMessages(),
         notes.getPatchSets().values(),
         notes.getApprovals().values(),
-        Iterables.concat(
-            plcUtil.draftByChange(null, notes),
-            plcUtil.publishedByChange(null, notes)),
+        Iterables.concat(CommentsUtil.toPatchLineComments(notes.getChangeId(),
+            PatchLineComment.Status.DRAFT, commentsUtil.draftByChange(null, notes)),
+            CommentsUtil.toPatchLineComments(notes.getChangeId(),
+                PatchLineComment.Status.PUBLISHED,
+                commentsUtil.publishedByChange(null, notes))),
         notes.getReviewers(),
         Source.NOTE_DB);
   }
@@ -241,18 +224,15 @@
     checkColumns(Change.Id.class, 1);
 
     checkColumns(Change.class,
-        1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18,
-        // TODO(dborowitz): It's potentially possible to compare noteDbState in
-        // the Change with the state implied by a ChangeNotes.
-        101);
+        1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 101);
     checkColumns(ChangeMessage.Key.class, 1, 2);
-    checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6);
+    checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6, 7);
     checkColumns(PatchSet.Id.class, 1, 2);
     checkColumns(PatchSet.class, 1, 2, 3, 4, 5, 6, 8);
     checkColumns(PatchSetApproval.Key.class, 1, 2, 3);
-    checkColumns(PatchSetApproval.class, 1, 2, 3, 6);
+    checkColumns(PatchSetApproval.class, 1, 2, 3, 6, 7, 8);
     checkColumns(PatchLineComment.Key.class, 1, 2);
-    checkColumns(PatchLineComment.class, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
+    checkColumns(PatchLineComment.class, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11);
   }
 
   private final Change change;
@@ -347,16 +327,16 @@
   private Timestamp getLatestTimestamp() {
     Ordering<Timestamp> o = Ordering.natural().nullsFirst();
     Timestamp ts = null;
-    for (ChangeMessage cm : getChangeMessages()) {
+    for (ChangeMessage cm : filterChangeMessages()) {
       ts = o.max(ts, cm.getWrittenOn());
     }
     for (PatchSet ps : getPatchSets()) {
       ts = o.max(ts, ps.getCreatedOn());
     }
-    for (PatchSetApproval psa : getPatchSetApprovals()) {
+    for (PatchSetApproval psa : filterPatchSetApprovals().values()) {
       ts = o.max(ts, psa.getGranted());
     }
-    for (PatchLineComment plc : getPatchLineComments()) {
+    for (PatchLineComment plc : filterPatchLineComments().values()) {
       // Ignore draft comments, as they do not show up in the change meta graph.
       if (plc.getStatus() != PatchLineComment.Status.DRAFT) {
         ts = o.max(ts, plc.getWrittenOn());
@@ -367,54 +347,36 @@
 
   private Map<PatchSetApproval.Key, PatchSetApproval>
       filterPatchSetApprovals() {
-    return limitToValidPatchSets(patchSetApprovals,
-        new Function<PatchSetApproval.Key, PatchSet.Id>() {
-          @Override
-          public PatchSet.Id apply(PatchSetApproval.Key in) {
-            return in.getParentKey();
-          }
-        });
+    return limitToValidPatchSets(
+        patchSetApprovals, PatchSetApproval.Key::getParentKey);
   }
 
   private Map<PatchLineComment.Key, PatchLineComment>
       filterPatchLineComments() {
-    return limitToValidPatchSets(patchLineComments,
-        new Function<PatchLineComment.Key, PatchSet.Id>() {
-          @Override
-          public PatchSet.Id apply(PatchLineComment.Key in) {
-            return in.getParentKey().getParentKey();
-          }
-        });
+    return limitToValidPatchSets(
+        patchLineComments,
+        k -> k.getParentKey().getParentKey());
   }
 
   private <K, V> Map<K, V> limitToValidPatchSets(Map<K, V> in,
-      final Function<K, PatchSet.Id> func) {
+      Function<K, PatchSet.Id> func) {
     return Maps.filterKeys(
         in, Predicates.compose(validPatchSetPredicate(), func));
   }
 
   private Predicate<PatchSet.Id> validPatchSetPredicate() {
-    final Predicate<PatchSet.Id> upToCurrent = upToCurrentPredicate();
-    return new Predicate<PatchSet.Id>() {
-      @Override
-      public boolean apply(PatchSet.Id in) {
-        return upToCurrent.apply(in) && patchSets.containsKey(in);
-      }
-    };
+    Predicate<PatchSet.Id> upToCurrent = upToCurrentPredicate();
+    return p -> upToCurrent.apply(p) && patchSets.containsKey(p);
   }
 
   private Collection<ChangeMessage> filterChangeMessages() {
     final Predicate<PatchSet.Id> validPatchSet = validPatchSetPredicate();
-    return Collections2.filter(changeMessages,
-        new Predicate<ChangeMessage>() {
-          @Override
-          public boolean apply(ChangeMessage in) {
-            PatchSet.Id psId = in.getPatchSetId();
-            if (psId == null) {
-              return true;
-            }
-            return validPatchSet.apply(psId);
+    return Collections2.filter(changeMessages, m -> {
+          PatchSet.Id psId = m.getPatchSetId();
+          if (psId == null) {
+            return true;
           }
+          return validPatchSet.apply(psId);
         });
   }
 
@@ -423,13 +385,8 @@
     if (current == null) {
       return Predicates.alwaysFalse();
     }
-    final int max = current.get();
-    return new Predicate<PatchSet.Id>() {
-      @Override
-      public boolean apply(PatchSet.Id in) {
-        return in.get() <= max;
-      }
-    };
+    int max = current.get();
+    return p -> p.get() <= max;
   }
 
   private Map<PatchSet.Id, PatchSet> filterPatchSets() {
@@ -669,17 +626,31 @@
     List<String> tempDiffs = new ArrayList<>();
     String temp = "temp";
 
+    // ReviewDb allows timestamps before patch set was created, but NoteDb
+    // truncates this to the patch set creation timestamp.
+    Timestamp ta = a.getWrittenOn();
+    Timestamp tb = b.getWrittenOn();
+    PatchSet psa = bundleA.patchSets.get(a.getPatchSetId());
+    PatchSet psb = bundleB.patchSets.get(b.getPatchSetId());
     boolean excludePatchSet = false;
+    boolean excludeWrittenOn = false;
     if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
       excludePatchSet = a.getPatchSetId() == null;
+      excludeWrittenOn = psa != null && psb != null
+          && ta.before(psa.getCreatedOn()) && tb.equals(psb.getCreatedOn());
     } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
       excludePatchSet = b.getPatchSetId() == null;
+      excludeWrittenOn = psa != null && psb != null
+          && tb.before(psb.getCreatedOn()) && ta.equals(psa.getCreatedOn());
     }
 
     List<String> exclude = Lists.newArrayList("key");
     if (excludePatchSet) {
       exclude.add("patchset");
     }
+    if (excludeWrittenOn) {
+      exclude.add("writtenOn");
+    }
 
     diffColumnsExcluding(
         tempDiffs, ChangeMessage.class, temp, bundleA, a, bundleB, b, exclude);
@@ -718,7 +689,35 @@
       PatchSetApproval a = as.get(k);
       PatchSetApproval b = bs.get(k);
       String desc = describe(k);
-      diffColumns(diffs, PatchSetApproval.class, desc, bundleA, a, bundleB, b);
+
+      // ReviewDb allows timestamps before patch set was created, but NoteDb
+      // truncates this to the patch set creation timestamp.
+      //
+      // ChangeRebuilder ensures all post-submit approvals happen after the
+      // actual submit, so the timestamps may not line up. This shouldn't really
+      // happen, because postSubmit shouldn't be set in ReviewDb until after the
+      // change is submitted in ReviewDb, but you never know.
+      Timestamp ta = a.getGranted();
+      Timestamp tb = b.getGranted();
+      PatchSet psa = checkNotNull(bundleA.patchSets.get(a.getPatchSetId()));
+      PatchSet psb = checkNotNull(bundleB.patchSets.get(b.getPatchSetId()));
+      boolean excludeGranted = false;
+      List<String> exclude = new ArrayList<>(1);
+      if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+        excludeGranted =
+            (ta.before(psa.getCreatedOn()) && tb.equals(psb.getCreatedOn()))
+            || ta.compareTo(tb) < 0;
+      } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+        excludeGranted =
+            tb.before(psb.getCreatedOn()) && ta.equals(psa.getCreatedOn())
+            || tb.compareTo(ta) < 0;
+      }
+      if (excludeGranted) {
+        exclude.add("granted");
+      }
+
+      diffColumnsExcluding(
+          diffs, PatchSetApproval.class, desc, bundleA, a, bundleB, b, exclude);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
similarity index 60%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
index 9bcabc3..9e7a1fe1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.server.OrmException;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
+public interface ChangeBundleReader {
+  ChangeBundle fromReviewDb(ReviewDb db, Change.Id id) throws OrmException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 7b59a47..57d5dce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -61,25 +61,34 @@
  */
 public class ChangeDraftUpdate extends AbstractChangeUpdate {
   public interface Factory {
-    ChangeDraftUpdate create(ChangeNotes notes, Account.Id accountId,
-        PersonIdent authorIdent, Date when);
-    ChangeDraftUpdate create(Change change, Account.Id accountId,
-        PersonIdent authorIdent, Date when);
+    ChangeDraftUpdate create(
+        ChangeNotes notes,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+
+    ChangeDraftUpdate create(
+        Change change,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
   }
 
   @AutoValue
   abstract static class Key {
-    abstract RevId revId();
-    abstract PatchLineComment.Key key();
+    abstract String revId();
+    abstract Comment.Key key();
   }
 
-  private static Key key(PatchLineComment c) {
-    return new AutoValue_ChangeDraftUpdate_Key(c.getRevId(), c.getKey());
+  private static Key key(Comment c) {
+    return new AutoValue_ChangeDraftUpdate_Key(c.revId, c.key);
   }
 
   private final AllUsersName draftsProject;
 
-  private List<PatchLineComment> put = new ArrayList<>();
+  private List<Comment> put = new ArrayList<>();
   private Set<Key> delete = new HashSet<>();
 
   @AssistedInject
@@ -90,11 +99,12 @@
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       @Assisted ChangeNotes notes,
-      @Assisted Account.Id accountId,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
     super(migration, noteUtil, serverIdent, anonymousCowardName, notes, null,
-        accountId, authorIdent, when);
+        accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
 
@@ -106,51 +116,44 @@
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
-      @Assisted Account.Id accountId,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
     super(migration, noteUtil, serverIdent, anonymousCowardName, null, change,
-        accountId, authorIdent, when);
+        accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
 
-  public void putComment(PatchLineComment c) {
+  public void putComment(Comment c) {
     verifyComment(c);
-    checkArgument(c.getStatus() == PatchLineComment.Status.DRAFT,
-        "Cannot insert a published comment into a ChangeDraftUpdate");
     put.add(c);
   }
 
-  public void deleteComment(PatchLineComment c) {
+  public void deleteComment(Comment c) {
     verifyComment(c);
     delete.add(key(c));
   }
 
-  public void deleteComment(RevId revId, PatchLineComment.Key key) {
+  public void deleteComment(String revId, Comment.Key key) {
     delete.add(new AutoValue_ChangeDraftUpdate_Key(revId, key));
   }
 
-  private void verifyComment(PatchLineComment comment) {
-    checkArgument(comment.getAuthor().equals(accountId),
-        "The author for the following comment does not match the author of"
-        + " this ChangeDraftUpdate (%s): %s", accountId, comment);
-  }
-
   private CommitBuilder storeCommentsInNotes(RevWalk rw, ObjectInserter ins,
       ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, OrmException, IOException {
-    RevisionNoteMap rnm = getRevisionNoteMap(rw, curr);
+    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
     Set<RevId> updatedRevs =
         Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
-    for (PatchLineComment c : put) {
+    for (Comment c : put) {
       if (!delete.contains(key(c))) {
-        cache.get(c.getRevId()).putComment(c);
+        cache.get(new RevId(c.revId)).putComment(c);
       }
     }
     for (Key k : delete) {
-      cache.get(k.revId()).deleteComment(k.key());
+      cache.get(new RevId(k.revId())).deleteComment(k.key());
     }
 
     Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
@@ -159,7 +162,7 @@
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
       updatedRevs.add(e.getKey());
       ObjectId id = ObjectId.fromString(e.getKey().get());
-      byte[] data = e.getValue().build(noteUtil);
+      byte[] data = e.getValue().build(noteUtil, noteUtil.getWriteJson());
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
       }
@@ -190,8 +193,8 @@
     return cb;
   }
 
-  private RevisionNoteMap getRevisionNoteMap(RevWalk rw, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
+  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw,
+      ObjectId curr) throws ConfigInvalidException, OrmException, IOException {
     if (migration.readChanges()) {
       // If reading from changes is enabled, then the old DraftCommentNotes
       // already parsed the revision notes. We can reuse them as long as the ref
@@ -203,7 +206,8 @@
         if (draftNotes != null) {
           ObjectId idFromNotes =
               firstNonNull(draftNotes.getRevision(), ObjectId.zeroId());
-          RevisionNoteMap rnm = draftNotes.getRevisionNoteMap();
+          RevisionNoteMap<ChangeRevisionNote> rnm =
+              draftNotes.getRevisionNoteMap();
           if (idFromNotes.equals(curr) && rnm != null) {
             return rnm;
           }
@@ -219,7 +223,10 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil, getId(), rw.getObjectReader(), noteMap, true);
+        noteUtil, getId(),
+        rw.getObjectReader(),
+        noteMap,
+        PatchLineComment.Status.DRAFT);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 4c1a734..239b54e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.PatchLineCommentsUtil.PLC_ORDER;
+import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 import static com.google.gerrit.server.notedb.ChangeNotes.parseException;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -25,20 +25,21 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.util.GitDateFormatter;
@@ -54,6 +55,7 @@
 import java.sql.Timestamp;
 import java.text.ParseException;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
@@ -61,20 +63,23 @@
 import java.util.Set;
 
 public class ChangeNoteUtil {
-  static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
-  static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
-  static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
-  static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
-  static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
-  static final FooterKey FOOTER_LABEL = new FooterKey("Label");
-  static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
-  static final FooterKey FOOTER_STATUS = new FooterKey("Status");
-  static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
-  static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
-  static final FooterKey FOOTER_SUBMITTED_WITH =
+  public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
+  public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
+  public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
+  public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
+  public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
+  public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
+  public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
+  public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
+  public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
+  public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
+  public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
+  public static final FooterKey FOOTER_SUBMISSION_ID =
+      new FooterKey("Submission-id");
+  public static final FooterKey FOOTER_SUBMITTED_WITH =
       new FooterKey("Submitted-with");
-  static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
-  static final FooterKey FOOTER_TAG = new FooterKey("Tag");
+  public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
+  public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
 
   private static final String AUTHOR = "Author";
   private static final String BASE_PATCH_SET = "Base-for-patch-set";
@@ -84,6 +89,7 @@
   private static final String PARENT = "Parent";
   private static final String PARENT_NUMBER = "Parent-number";
   private static final String PATCH_SET = "Patch-set";
+  private static final String REAL_AUTHOR = "Real-author";
   private static final String REVISION = "Revision";
   private static final String UUID = "UUID";
   private static final String TAG = FOOTER_TAG.getName();
@@ -99,16 +105,20 @@
   private final PersonIdent serverIdent;
   private final String anonymousCowardName;
   private final String serverId;
+  private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
+  private final boolean writeJson;
 
   @Inject
   public ChangeNoteUtil(AccountCache accountCache,
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
-      @GerritServerId String serverId) {
+      @GerritServerId String serverId,
+      @GerritServerConfig Config config) {
     this.accountCache = accountCache;
     this.serverIdent = serverIdent;
     this.anonymousCowardName = anonymousCowardName;
     this.serverId = serverId;
+    this.writeJson = config.getBoolean("notedb", "writeJson", false);
   }
 
   @VisibleForTesting
@@ -120,6 +130,18 @@
         when, serverIdent.getTimeZone());
   }
 
+  public boolean getWriteJson() {
+    return writeJson;
+  }
+
+  public Gson getGson() {
+    return gson;
+  }
+
+  public String getServerId() {
+    return serverId;
+  }
+
   public Account.Id parseIdent(PersonIdent ident, Change.Id changeId)
       throws ConfigInvalidException {
     String email = ident.getEmailAddress();
@@ -142,13 +164,13 @@
     return m == p.value + expected.length;
   }
 
-  public List<PatchLineComment> parseNote(byte[] note, MutableInteger p,
-      Change.Id changeId, Status status) throws ConfigInvalidException {
+  public List<Comment> parseNote(byte[] note, MutableInteger p,
+      Change.Id changeId) throws ConfigInvalidException {
     if (p.value >= note.length) {
       return ImmutableList.of();
     }
-    Set<PatchLineComment.Key> seen = new HashSet<>();
-    List<PatchLineComment> result = new ArrayList<>();
+    Set<Comment.Key> seen = new HashSet<>();
+    List<Comment> result = new ArrayList<>();
     int sizeOfNote = note.length;
     byte[] psb = PATCH_SET.getBytes(UTF_8);
     byte[] bpsb = BASE_PATCH_SET.getBytes(UTF_8);
@@ -179,21 +201,21 @@
             PATCH_SET, BASE_PATCH_SET);
       }
 
-      PatchLineComment c = parseComment(
-          note, p, fileName, psId, revId, isForBase, parentNumber, status);
-      fileName = c.getKey().getParentKey().getFileName();
-      if (!seen.add(c.getKey())) {
+      Comment c = parseComment(
+          note, p, fileName, psId, revId, isForBase, parentNumber);
+      fileName = c.key.filename;
+      if (!seen.add(c.key)) {
         throw parseException(
-            changeId, "multiple comments for %s in note", c.getKey());
+            changeId, "multiple comments for %s in note", c.key);
       }
       result.add(c);
     }
     return result;
   }
 
-  private PatchLineComment parseComment(byte[] note, MutableInteger curr,
+  private Comment parseComment(byte[] note, MutableInteger curr,
       String currentFileName, PatchSet.Id psId, RevId revId, boolean isForBase,
-      Integer parentNumber, Status status) throws ConfigInvalidException {
+      Integer parentNumber) throws ConfigInvalidException {
     Change.Id changeId = psId.getParentKey();
 
     // Check if there is a new file.
@@ -212,7 +234,14 @@
     }
 
     Timestamp commentTime = parseTimestamp(note, curr, changeId);
-    Account.Id aId = parseAuthor(note, curr, changeId);
+    Account.Id aId = parseAuthor(note, curr, changeId, AUTHOR);
+    boolean hasRealAuthor =
+        (RawParseUtils.match(note, curr.value, REAL_AUTHOR.getBytes(UTF_8)))
+            != -1;
+    Account.Id raId = null;
+    if (hasRealAuthor) {
+      raId = parseAuthor(note, curr, changeId, REAL_AUTHOR);
+    }
 
     boolean hasParent =
         (RawParseUtils.match(note, curr.value, PARENT.getBytes(UTF_8))) != -1;
@@ -236,27 +265,30 @@
         UTF_8, note, curr.value, curr.value + commentLength);
     checkResult(message, "message contents", changeId);
 
-    PatchLineComment plc = new PatchLineComment(
-        new PatchLineComment.Key(new Patch.Key(psId, currentFileName), uuid),
-        range.getEndLine(), aId, parentUUID, commentTime);
-    plc.setMessage(message);
-    plc.setTag(tag);
-
-    if (isForBase) {
-      plc.setSide((short) (parentNumber == null ? 0 : -parentNumber));
-    } else {
-      plc.setSide((short) 1);
+    Comment c = new Comment(
+        new Comment.Key(uuid, currentFileName, psId.get()),
+        aId,
+        commentTime,
+        isForBase
+            ? (short) (parentNumber == null ? 0 : -parentNumber)
+            : (short) 1,
+        message,
+        serverId);
+    c.lineNbr = range.getEndLine();
+    c.parentUuid = parentUUID;
+    c.tag = tag;
+    c.setRevId(revId);
+    if (raId != null) {
+      c.setRealAuthor(raId);
     }
 
     if (range.getStartCharacter() != -1) {
-      plc.setRange(range);
+      c.setRange(range);
     }
-    plc.setRevId(revId);
-    plc.setStatus(status);
 
     curr.value = RawParseUtils.nextLF(note, curr.value + commentLength);
     curr.value = RawParseUtils.nextLF(note, curr.value);
-    return plc;
+    return c;
   }
 
   private static String parseStringField(byte[] note, MutableInteger curr,
@@ -391,15 +423,15 @@
   }
 
   private Account.Id parseAuthor(byte[] note, MutableInteger curr,
-      Change.Id changeId) throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, AUTHOR, changeId);
+      Change.Id changeId, String fieldName) throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, fieldName, changeId);
     int startOfAccountId =
         RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
     PersonIdent ident =
         RawParseUtils.parsePersonIdent(note, startOfAccountId);
     Account.Id aId = parseIdent(ident, changeId);
     curr.value = RawParseUtils.nextLF(note, curr.value);
-    return checkResult(aId, "comment author", changeId);
+    return checkResult(aId, fieldName, changeId);
   }
 
   private static int parseCommentLength(byte[] note, MutableInteger curr,
@@ -470,47 +502,45 @@
    *     side.
    * @param out output stream to write to.
    */
-  void buildNote(Multimap<PatchSet.Id, PatchLineComment> comments,
+  void buildNote(Multimap<Integer, Comment> comments,
       OutputStream out) {
     if (comments.isEmpty()) {
       return;
     }
 
-    List<PatchSet.Id> psIds =
-        ReviewDbUtil.intKeyOrdering().sortedCopy(comments.keySet());
+    List<Integer> psIds = new ArrayList<>(comments.keySet());
+    Collections.sort(psIds);
 
     OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
     try (PrintWriter writer = new PrintWriter(streamWriter)) {
-      RevId revId = comments.values().iterator().next().getRevId();
-      appendHeaderField(writer, REVISION, revId.get());
+      String revId = comments.values().iterator().next().revId;
+      appendHeaderField(writer, REVISION, revId);
 
-      for (PatchSet.Id psId : psIds) {
-        List<PatchLineComment> psComments =
-            PLC_ORDER.sortedCopy(comments.get(psId));
-        PatchLineComment first = psComments.get(0);
+      for (int psId : psIds) {
+        List<Comment> psComments = COMMENT_ORDER.sortedCopy(comments.get(psId));
+        Comment first = psComments.get(0);
 
-        short side = first.getSide();
+        short side = first.side;
         appendHeaderField(writer, side <= 0
             ? BASE_PATCH_SET
             : PATCH_SET,
-            Integer.toString(psId.get()));
+            Integer.toString(psId));
         if (side < 0) {
           appendHeaderField(writer, PARENT_NUMBER, Integer.toString(-side));
         }
 
         String currentFilename = null;
 
-        for (PatchLineComment c : psComments) {
-          checkArgument(revId.equals(c.getRevId()),
+        for (Comment c : psComments) {
+          checkArgument(revId.equals(c.revId),
               "All comments being added must have all the same RevId. The "
               + "comment below does not have the same RevId as the others "
               + "(%s).\n%s", revId, c);
-          checkArgument(side == c.getSide(),
+          checkArgument(side == c.side,
               "All comments being added must all have the same side. The "
               + "comment below does not have the same side as the others "
               + "(%s).\n%s", side, c);
-          String commentFilename = QuotedString.GIT_PATH.quote(
-              c.getKey().getParentKey().getFileName());
+          String commentFilename = QuotedString.GIT_PATH.quote(c.key.filename);
 
           if (!commentFilename.equals(currentFilename)) {
             currentFilename = commentFilename;
@@ -525,53 +555,61 @@
     }
   }
 
-  private void appendOneComment(PrintWriter writer, PatchLineComment c) {
+  private void appendOneComment(PrintWriter writer, Comment c) {
     // The CommentRange field for a comment is allowed to be null. If it is
     // null, then in the first line, we simply use the line number field for a
     // comment instead. If it isn't null, we write the comment range itself.
-    CommentRange range = c.getRange();
+    Comment.Range range = c.range;
     if (range != null) {
-      writer.print(range.getStartLine());
+      writer.print(range.startLine);
       writer.print(':');
-      writer.print(range.getStartCharacter());
+      writer.print(range.startChar);
       writer.print('-');
-      writer.print(range.getEndLine());
+      writer.print(range.endLine);
       writer.print(':');
-      writer.print(range.getEndCharacter());
+      writer.print(range.endChar);
     } else {
-      writer.print(c.getLine());
+      writer.print(c.lineNbr);
     }
     writer.print("\n");
 
-    writer.print(formatTime(serverIdent, c.getWrittenOn()));
+    writer.print(formatTime(serverIdent, c.writtenOn));
     writer.print("\n");
 
+    appendIdent(writer, AUTHOR, c.author.getId(), c.writtenOn);
+    if (!c.getRealAuthor().equals(c.author)) {
+      appendIdent(writer, REAL_AUTHOR, c.getRealAuthor().getId(), c.writtenOn);
+    }
+
+    String parent = c.parentUuid;
+    if (parent != null) {
+      appendHeaderField(writer, PARENT, parent);
+    }
+
+    appendHeaderField(writer, UUID, c.key.uuid);
+
+    if (c.tag != null) {
+      appendHeaderField(writer, TAG, c.tag);
+    }
+
+    byte[] messageBytes = c.message.getBytes(UTF_8);
+    appendHeaderField(writer, LENGTH,
+        Integer.toString(messageBytes.length));
+
+    writer.print(c.message);
+    writer.print("\n\n");
+  }
+
+  private void appendIdent(PrintWriter writer, String header, Account.Id id,
+      Timestamp ts) {
     PersonIdent ident = newIdent(
-        accountCache.get(c.getAuthor()).getAccount(),
-        c.getWrittenOn(), serverIdent, anonymousCowardName);
+        accountCache.get(id).getAccount(),
+        ts, serverIdent, anonymousCowardName);
     StringBuilder name = new StringBuilder();
     PersonIdent.appendSanitized(name, ident.getName());
     name.append(" <");
     PersonIdent.appendSanitized(name, ident.getEmailAddress());
     name.append('>');
-    appendHeaderField(writer, AUTHOR, name.toString());
-
-    String parent = c.getParentUuid();
-    if (parent != null) {
-      appendHeaderField(writer, PARENT, parent);
-    }
-
-    appendHeaderField(writer, UUID, c.getKey().get());
-
-    if (c.getTag() != null) {
-      appendHeaderField(writer, TAG, c.getTag());
-    }
-
-    byte[] messageBytes = c.getMessage().getBytes(UTF_8);
-    appendHeaderField(writer, LENGTH,
-        Integer.toString(messageBytes.length));
-
-    writer.print(c.getMessage());
-    writer.print("\n\n");
+    appendHeaderField(writer, header, name.toString());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 6327682..eda50d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -19,15 +19,14 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+import static java.util.Comparator.comparing;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.Predicate;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
@@ -40,18 +39,21 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.git.RefCache;
 import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -69,7 +71,6 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
@@ -77,28 +78,17 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
 
 /** View of a single {@link Change} based on the log of its notes branch. */
 public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
   private static final Logger log = LoggerFactory.getLogger(ChangeNotes.class);
 
   static final Ordering<PatchSetApproval> PSA_BY_TIME =
-      Ordering.natural().onResultOf(
-        new Function<PatchSetApproval, Timestamp>() {
-          @Override
-          public Timestamp apply(PatchSetApproval input) {
-            return input.getGranted();
-          }
-        });
+      Ordering.from(comparing(PatchSetApproval::getGranted));
 
   public static final Ordering<ChangeMessage> MESSAGE_BY_TIME =
-      Ordering.natural().onResultOf(
-        new Function<ChangeMessage, Timestamp>() {
-          @Override
-          public Timestamp apply(ChangeMessage input) {
-            return input.getWrittenOn();
-          }
-        });
+      Ordering.from(comparing(ChangeMessage::getWrittenOn));
 
   public static ConfigInvalidException parseException(Change.Id changeId,
       String fmt, Object... args) {
@@ -106,6 +96,20 @@
         + String.format(fmt, args));
   }
 
+  public static Change readOneReviewDbChange(ReviewDb db, Change.Id id)
+      throws OrmException {
+    return checkNoteDbState(ReviewDbUtil.unwrapDb(db).changes().get(id));
+  }
+
+  private static Change checkNoteDbState(Change c) throws OrmException {
+    NoteDbChangeState s = NoteDbChangeState.parse(c);
+    if (s != null && s.getPrimaryStorage() != PrimaryStorage.REVIEW_DB) {
+      throw new OrmException(
+          "invalid NoteDbChangeState in " + c.getId() + ": " + s);
+    }
+    return c;
+  }
+
   @Singleton
   public static class Factory {
     private final Args args;
@@ -129,7 +133,7 @@
 
     public ChangeNotes createChecked(ReviewDb db, Project.NameKey project,
         Change.Id changeId) throws OrmException, NoSuchChangeException {
-      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
+      Change change = readOneReviewDbChange(db, changeId);
       if (change == null || !change.getProject().equals(project)) {
         throw new NoSuchChangeException(changeId);
       }
@@ -153,7 +157,8 @@
 
     private Change loadChangeFromDb(ReviewDb db, Project.NameKey project,
         Change.Id changeId) throws OrmException {
-      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
+      Change change = readOneReviewDbChange(db, changeId);
+      checkArgument(project != null, "project is required");
       checkNotNull(change,
           "change %s not found in ReviewDb", changeId);
       checkArgument(change.getProject().equals(project),
@@ -193,17 +198,6 @@
       return new ChangeNotes(args, change, false, null).load();
     }
 
-    // TODO(dborowitz): Remove when deleting index schemas <27.
-    public ChangeNotes createFromIdOnlyWhenNoteDbDisabled(
-        ReviewDb db, Change.Id changeId) throws OrmException {
-      checkState(!args.migration.readChanges(), "do not call"
-          + " createFromIdOnlyWhenNoteDbDisabled when NoteDb is enabled");
-      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
-      checkNotNull(change,
-          "change %s not found in ReviewDb", changeId);
-      return new ChangeNotes(args, change).load();
-    }
-
     public ChangeNotes createWithAutoRebuildingDisabled(Change change,
         RefCache refs) throws OrmException {
       return new ChangeNotes(args, change, false, refs).load();
@@ -248,7 +242,7 @@
       if (args.migration.enabled()) {
         for (Change.Id cid : changeIds) {
           ChangeNotes cn = create(db, project, cid);
-          if (cn.getChange() != null && predicate.apply(cn)) {
+          if (cn.getChange() != null && predicate.test(cn)) {
             notes.add(cn);
           }
         }
@@ -258,7 +252,7 @@
       for (Change c : ReviewDbUtil.unwrapDb(db).changes().get(changeIds)) {
         if (c != null && project.equals(c.getDest().getParentKey())) {
           ChangeNotes cn = createFromChangeOnlyWhenNoteDbDisabled(c);
-          if (predicate.apply(cn)) {
+          if (predicate.test(cn)) {
             notes.add(cn);
           }
         }
@@ -274,7 +268,7 @@
           try (Repository repo = args.repoManager.openRepository(project)) {
             List<ChangeNotes> changes = scanNoteDb(repo, db, project);
             for (ChangeNotes cn : changes) {
-              if (predicate.apply(cn)) {
+              if (predicate.test(cn)) {
                 m.put(project, cn);
               }
             }
@@ -282,8 +276,9 @@
         }
       } else {
         for (Change change : ReviewDbUtil.unwrapDb(db).changes().all()) {
+          checkNoteDbState(change);
           ChangeNotes notes = createFromChangeOnlyWhenNoteDbDisabled(change);
-          if (predicate.apply(notes)) {
+          if (predicate.test(notes)) {
             m.put(change.getProject(), notes);
           }
         }
@@ -318,9 +313,8 @@
         Project.NameKey project) throws OrmException, IOException {
       Set<Change.Id> ids = scan(repo);
       List<ChangeNotes> changeNotes = new ArrayList<>(ids.size());
-      db = ReviewDbUtil.unwrapDb(db);
       for (Change.Id id : ids) {
-        Change change = db.changes().get(id);
+        Change change = readOneReviewDbChange(db, id);
         if (change == null) {
           log.warn("skipping change {} found in project {} " +
               "but not in ReviewDb",
@@ -361,10 +355,16 @@
 
   // Parsed note map state, used by ChangeUpdate to make in-place editing of
   // notes easier.
-  RevisionNoteMap revisionNoteMap;
+  RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   private NoteDbUpdateManager.Result rebuildResult;
   private DraftCommentNotes draftCommentNotes;
+  private RobotCommentNotes robotCommentNotes;
+
+  // Lazy defensive copies of mutable ReviewDb types, to avoid polluting the
+  // ChangeNotesCache from handlers.
+  private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
+  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
 
   @VisibleForTesting
   public ChangeNotes(Args args, Change change) {
@@ -382,12 +382,28 @@
     return change;
   }
 
-  public ImmutableMap<PatchSet.Id, PatchSet> getPatchSets() {
-    return state.patchSets();
+  public ImmutableSortedMap<PatchSet.Id, PatchSet> getPatchSets() {
+    if (patchSets == null) {
+      ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
+          ImmutableSortedMap.orderedBy(comparing(PatchSet.Id::get));
+      for (Map.Entry<PatchSet.Id, PatchSet> e : state.patchSets()) {
+        b.put(e.getKey(), new PatchSet(e.getValue()));
+      }
+      patchSets = b.build();
+    }
+    return patchSets;
   }
 
   public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
-    return state.approvals();
+    if (approvals == null) {
+      ImmutableListMultimap.Builder<PatchSet.Id, PatchSetApproval> b =
+          ImmutableListMultimap.builder();
+      for (Map.Entry<PatchSet.Id, PatchSetApproval> e : state.approvals()) {
+        b.put(e.getKey(), new PatchSetApproval(e.getValue()));
+      }
+      approvals = b.build();
+    }
+    return approvals;
   }
 
   public ReviewerSet getReviewers() {
@@ -399,8 +415,16 @@
   }
 
   /**
-   *
-   * @return a ImmutableSet of all hashtags for this change sorted in alphabetical order.
+   * @return an ImmutableSet of Account.Ids of all users that have been assigned
+   *         to this change.
+   */
+  public ImmutableSet<Account.Id> getPastAssignees() {
+    return state.pastAssignees();
+  }
+
+  /**
+   * @return a ImmutableSet of all hashtags for this change sorted in
+   *         alphabetical order.
    */
   public ImmutableSet<String> getHashtags() {
     return ImmutableSortedSet.copyOf(state.hashtags());
@@ -436,35 +460,38 @@
   }
 
   /** @return inline comments on each revision. */
-  public ImmutableListMultimap<RevId, PatchLineComment> getComments() {
+  public ImmutableListMultimap<RevId, Comment> getComments() {
     return state.publishedComments();
   }
 
-  public ImmutableListMultimap<RevId, PatchLineComment> getDraftComments(
+  public ImmutableListMultimap<RevId, Comment> getDraftComments(
       Account.Id author) throws OrmException {
     loadDraftComments(author);
-    final Multimap<RevId, PatchLineComment> published =
+    final Multimap<RevId, Comment> published =
         state.publishedComments();
     // Filter out any draft comments that also exist in the published map, in
     // case the update to All-Users to delete them during the publish operation
     // failed.
-    Multimap<RevId, PatchLineComment> filtered = Multimaps.filterEntries(
+    Multimap<RevId, Comment> filtered = Multimaps.filterEntries(
         draftCommentNotes.getComments(),
-        new Predicate<Map.Entry<RevId, PatchLineComment>>() {
-          @Override
-          public boolean apply(Map.Entry<RevId, PatchLineComment> in) {
-            for (PatchLineComment c : published.get(in.getKey())) {
-              if (c.getKey().equals(in.getValue().getKey())) {
+        (Map.Entry<RevId, Comment> e) -> {
+            for (Comment c : published.get(e.getKey())) {
+              if (c.key.equals(e.getValue().key)) {
                 return false;
               }
             }
             return true;
-          }
         });
     return ImmutableListMultimap.copyOf(
         filtered);
   }
 
+  public ImmutableListMultimap<RevId, RobotComment> getRobotComments()
+      throws OrmException {
+    loadRobotComments();
+    return robotCommentNotes.getComments();
+  }
+
   /**
    * If draft comments have already been loaded for this author, then they will
    * not be reloaded. However, this method will load the comments if no draft
@@ -481,22 +508,33 @@
     }
   }
 
+  private void loadRobotComments() throws OrmException {
+    if (robotCommentNotes == null) {
+      robotCommentNotes = new RobotCommentNotes(args, change);
+      robotCommentNotes.load();
+    }
+  }
+
   @VisibleForTesting
   DraftCommentNotes getDraftCommentNotes() {
     return draftCommentNotes;
   }
 
-  public boolean containsComment(PatchLineComment c) throws OrmException {
+  RobotCommentNotes getRobotCommentNotes() {
+    return robotCommentNotes;
+  }
+
+  public boolean containsComment(Comment c) throws OrmException {
     if (containsCommentPublished(c)) {
       return true;
     }
-    loadDraftComments(c.getAuthor());
+    loadDraftComments(c.author.getId());
     return draftCommentNotes.containsComment(c);
   }
 
-  public boolean containsCommentPublished(PatchLineComment c) {
-    for (PatchLineComment l : getComments().values()) {
-      if (c.getKey().equals(l.getKey())) {
+  public boolean containsCommentPublished(Comment c) {
+    for (Comment l : getComments().values()) {
+      if (c.key.equals(l.key)) {
         return true;
       }
     }
@@ -510,7 +548,7 @@
 
   public PatchSet getCurrentPatchSet() {
     PatchSet.Id psId = change.currentPatchSetId();
-    return checkNotNull(state.patchSets().get(psId),
+    return checkNotNull(getPatchSets().get(psId),
         "missing current patch set %s", psId.get());
   }
 
@@ -543,7 +581,7 @@
   @Override
   protected ObjectId readRef(Repository repo) throws IOException {
     return refs != null
-        ? refs.get(getRefName()).orNull()
+        ? refs.get(getRefName()).orElse(null)
         : super.readRef(repo);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index a8f85a4..73b7aec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -33,6 +33,9 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 
@@ -49,7 +52,8 @@
         cache(CACHE_NAME,
             Key.class,
             ChangeNotesState.class)
-          .maximumWeight(1000);
+          .weigher(Weigher.class)
+          .maximumWeight(10 << 20);
       }
     };
   }
@@ -61,6 +65,154 @@
     abstract ObjectId id();
   }
 
+  public static class Weigher
+      implements com.google.common.cache.Weigher<Key, ChangeNotesState> {
+    // Single object overhead.
+    private static final int O = 16;
+
+    // Single pointer overhead.
+    private static final int P = 8;
+
+    // Single IntKey overhead.
+    private static final int K = O + 4;
+
+    // Single Timestamp overhead.
+    private static final int T = O + 8;
+
+    @Override
+    public int weigh(Key key, ChangeNotesState state) {
+      // Take all columns and all collection sizes into account, but use
+      // estimated average element sizes rather than iterating over collections.
+      // Numbers are largely hand-wavy based on
+      // http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
+      return
+          K // changeId
+          + str(40) // changeKey
+          + T // createdOn
+          + T // lastUpdatedOn
+          + P + K // owner
+          + P + str(state.columns().branch())
+          + P + patchSetId() // currentPatchSetId
+          + P + str(state.columns().subject())
+          + P + str(state.columns().topic())
+          + P + str(state.columns().originalSubject())
+          + P + str(state.columns().submissionId())
+          + ptr(state.columns().assignee(), K) // assignee
+          + P // status
+          + P + set(state.pastAssignees(), K)
+          + P + set(state.hashtags(), str(10))
+          + P + list(state.patchSets(), patchSet())
+          + P + list(state.allPastReviewers(), approval())
+          + P + list(state.reviewerUpdates(), 4 * O + K + K + P)
+          + P + list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
+          + P + list(state.allChangeMessages(), changeMessage())
+          // Just key overhead for map, already counted messages in previous.
+          + P + map(state.changeMessagesByPatchSet().asMap(), patchSetId())
+          + P + map(state.publishedComments().asMap(), comment());
+    }
+
+    private static int ptr(Object o, int size) {
+      return o != null ? P + size : P;
+    }
+
+    private static int str(String s) {
+      if (s == null) {
+        return P;
+      }
+      return str(s.length());
+    }
+
+    private static int str(int n) {
+      return 8 + 24 + 2 * n;
+    }
+
+    private static int patchSetId() {
+      return O + 4 + O + 4;
+    }
+
+    private static int set(Set<?> set, int elemSize) {
+      if (set == null) {
+        return P;
+      }
+      return hashtable(set.size(), elemSize);
+    }
+
+    private static int map(Map<?, ?> map, int elemSize) {
+      if (map == null) {
+        return P;
+      }
+      return hashtable(map.size(), elemSize);
+    }
+
+    private static int hashtable(int n, int elemSize) {
+      // Made up numbers.
+      int overhead = 32;
+      int elemOverhead = O + 32;
+      return overhead + elemOverhead * n * elemSize;
+    }
+
+    private static int list(List<?> list, int elemSize) {
+      if (list == null) {
+        return P;
+      }
+      return list(list.size(), elemSize);
+    }
+
+    private static int list(int n, int elemSize) {
+      return O + O + n * (P + elemSize);
+    }
+
+    private static int patchSet() {
+      return O
+          + P + patchSetId()
+          + str(40) // revision
+          + P + K // uploader
+          + P + T // createdOn
+          + 1 // draft
+          + str(40) // groups
+          + P; // pushCertificate
+    }
+
+    private static int approval() {
+      return O
+          + P + patchSetId() + P + K + P + O + str(10)
+          + 2 // value
+          + P + T // granted
+          + P // tag
+          + P; // realAccountId
+    }
+
+    private static int changeMessage() {
+      int key = K + str(20);
+      return O
+          + P + key
+          + P + K // author
+          + P + T // writtenON
+          + str(64) // message
+          + P + patchSetId()
+          + P
+          + P; // realAuthor
+    }
+
+    private static int comment() {
+      int key = P + str(20) + P + str(32) + 4;
+      int ident = O + 4;
+      return O
+          + P + key
+          + 4 // lineNbr
+          + P + ident // author
+          + P + ident //realAuthor
+          + P + T // writtenOn
+          + 2 // side
+          + str(32) // message
+          + str(10) // parentUuid
+          + (P + O + 4 + 4 + 4 + 4) / 2 // range on 50% of comments
+          + P // tag
+          + P + str(40) // revId
+          + P + str(36); // serverId
+    }
+  }
+
   @AutoValue
   abstract static class Value {
     abstract ChangeNotesState state();
@@ -73,14 +225,14 @@
      * used as an optimization; {@link ChangeNotes} is capable of lazily loading
      * it as necessary.
      */
-    @Nullable abstract RevisionNoteMap revisionNoteMap();
+    @Nullable abstract RevisionNoteMap<ChangeRevisionNote> revisionNoteMap();
   }
 
   private class Loader implements Callable<ChangeNotesState> {
     private final Key key;
     private final ChangeNotesRevWalk rw;
 
-    private RevisionNoteMap revisionNoteMap;
+    private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
     private Loader(Key key, ChangeNotesRevWalk rw) {
       this.key = key;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 8272aaf..37fc9f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
@@ -21,6 +22,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
@@ -28,13 +30,12 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.joining;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Enums;
-import com.google.common.base.Function;
-import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
 import com.google.common.base.Splitter;
-import com.google.common.base.Supplier;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableSet;
@@ -52,6 +53,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -72,6 +74,8 @@
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.nio.charset.Charset;
@@ -85,18 +89,36 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.NavigableSet;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.function.Function;
 
 class ChangeNotesParser {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeNotesParser.class);
+
   // Sentinel RevId indicating a mutable field on a patch set was parsed, but
   // the parser does not yet know its commit SHA-1.
   private static final RevId PARTIAL_PATCH_SET =
       new RevId("INVALID PARTIAL PATCH SET");
 
+  @AutoValue
+  abstract static class ApprovalKey {
+    abstract PatchSet.Id psId();
+    abstract Account.Id accountId();
+    abstract String label();
+
+    private static ApprovalKey create(PatchSet.Id psId, Account.Id accountId,
+        String label) {
+      return new AutoValue_ChangeNotesParser_ApprovalKey(
+          psId, accountId, label);
+    }
+  }
+
   // Private final members initialized in the constructor.
   private final ChangeNoteUtil noteUtil;
   private final NoteDbMetrics metrics;
@@ -110,12 +132,12 @@
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   private final List<SubmitRecord> submitRecords;
-  private final Multimap<RevId, PatchLineComment> comments;
+  private final Multimap<RevId, Comment> comments;
   private final TreeMap<PatchSet.Id, PatchSet> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
-  private final Map<PatchSet.Id,
-      Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>>> approvals;
+  private final Map<ApprovalKey, PatchSetApproval> approvals;
+  private final List<PatchSetApproval> bufferedApprovals;
   private final List<ChangeMessage> allChangeMessages;
   private final Multimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet;
 
@@ -123,6 +145,8 @@
   private String branch;
   private Change.Status status;
   private String topic;
+  private Optional<Account.Id> assignee;
+  private List<Account.Id> pastAssignees;
   private Set<String> hashtags;
   private Timestamp createdOn;
   private Timestamp lastUpdatedOn;
@@ -133,7 +157,7 @@
   private String submissionId;
   private String tag;
   private PatchSet.Id currentPatchSetId;
-  private RevisionNoteMap revisionNoteMap;
+  private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   ChangeNotesParser(Change.Id changeId, ObjectId tip, ChangeNotesRevWalk walk,
       ChangeNoteUtil noteUtil, NoteDbMetrics metrics) {
@@ -142,7 +166,8 @@
     this.walk = walk;
     this.noteUtil = noteUtil;
     this.metrics = metrics;
-    approvals = new HashMap<>();
+    approvals = new LinkedHashMap<>();
+    bufferedApprovals = new ArrayList<>();
     reviewers = HashBasedTable.create();
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
@@ -150,7 +175,7 @@
     allChangeMessages = new ArrayList<>();
     changeMessagesByPatchSet = LinkedListMultimap.create();
     comments = ArrayListMultimap.create();
-    patchSets = Maps.newTreeMap(ReviewDbUtil.intKeyOrdering());
+    patchSets = Maps.newTreeMap(comparing(PatchSet.Id::get));
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
   }
@@ -171,6 +196,7 @@
       parseNotes();
       allPastReviewers.addAll(reviewers.rowKeySet());
       pruneReviewers();
+
       updatePatchSetStates();
       checkMandatoryFooters();
     }
@@ -178,7 +204,7 @@
     return buildState();
   }
 
-  RevisionNoteMap getRevisionNoteMap() {
+  RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap() {
     return revisionNoteMap;
   }
 
@@ -195,8 +221,10 @@
         topic,
         originalSubject,
         submissionId,
+        assignee != null ? assignee.orElse(null) : null,
         status,
 
+        Sets.newLinkedHashSet(Lists.reverse(pastAssignees)),
         hashtags,
         patchSets,
         buildApprovals(),
@@ -210,14 +238,15 @@
   }
 
   private Multimap<PatchSet.Id, PatchSetApproval> buildApprovals() {
-    Multimap<PatchSet.Id, PatchSetApproval> result =
-        ArrayListMultimap.create(approvals.keySet().size(), 3);
-    for (Table<?, ?, Optional<PatchSetApproval>> curr : approvals.values()) {
-      for (Optional<PatchSetApproval> psa : curr.values()) {
-        if (psa.isPresent()) {
-          result.put(psa.get().getPatchSetId(), psa.get());
-        }
+    Multimap<PatchSet.Id, PatchSetApproval> result = ArrayListMultimap.create();
+    for (PatchSetApproval a : approvals.values()) {
+      if (!patchSets.containsKey(a.getPatchSetId())) {
+        continue; // Patch set deleted or missing.
+      } else if (allPastReviewers.contains(a.getAccountId())
+          && !reviewers.containsRow(a.getAccountId())) {
+        continue; // Reviewer was explicitly removed.
       }
+      result.put(a.getPatchSetId(), a);
     }
     for (Collection<PatchSetApproval> v : result.asMap().values()) {
       Collections.sort((List<PatchSetApproval>) v, ChangeNotes.PSA_BY_TIME);
@@ -260,15 +289,8 @@
     if (branch == null) {
       branch = parseBranch(commit);
     }
-    if (status == null) {
-      status = parseStatus(commit);
-    }
 
     PatchSet.Id psId = parsePatchSetId(commit);
-    if (currentPatchSetId == null || psId.get() > currentPatchSetId.get()) {
-      currentPatchSetId = psId;
-    }
-
     PatchSetState psState = parsePatchSetState(commit);
     if (psState != null) {
       if (!patchSetStates.containsKey(psId)) {
@@ -283,6 +305,7 @@
     if (accountId != null) {
       ownerId = accountId;
     }
+    Account.Id realAccountId = parseRealAccountId(commit, accountId);
 
     if (changeId == null) {
       changeId = parseChangeId(commit);
@@ -296,13 +319,15 @@
       originalSubject = currSubject;
     }
 
-    parseChangeMessage(psId, accountId, commit, ts);
+    parseChangeMessage(psId, accountId, realAccountId, commit, ts);
     if (topic == null) {
       topic = parseTopic(commit);
     }
 
     parseHashtags(commit);
 
+    parseAssignee(commit);
+
     if (submissionId == null) {
       submissionId = parseSubmissionId(commit);
     }
@@ -319,8 +344,14 @@
       parseSubmitRecords(commit.getFooterLineValues(FOOTER_SUBMITTED_WITH));
     }
 
+    if (status == null) {
+      status = parseStatus(commit);
+    }
+
+    // Parse approvals after status to treat approvals in the same commit as
+    // "Status: merged" as non-post-submit.
     for (String line : commit.getFooterLineValues(FOOTER_LABEL)) {
-      parseApproval(psId, accountId, ts, line);
+      parseApproval(psId, accountId, realAccountId, ts, line);
     }
 
     for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
@@ -357,6 +388,16 @@
     return parseOneFooter(commit, FOOTER_SUBJECT);
   }
 
+  private Account.Id parseRealAccountId(ChangeNotesCommit commit,
+      Account.Id effectiveAccountId) throws ConfigInvalidException {
+    String realUser = parseOneFooter(commit, FOOTER_REAL_USER);
+    if (realUser == null) {
+      return effectiveAccountId;
+    }
+    PersonIdent ident = RawParseUtils.parsePersonIdent(realUser);
+    return noteUtil.parseIdent(ident, id);
+  }
+
   private String parseTopic(ChangeNotesCommit commit)
       throws ConfigInvalidException {
     return parseOneFooter(commit, FOOTER_TOPIC);
@@ -459,6 +500,30 @@
     }
   }
 
+  private void parseAssignee(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    if (pastAssignees == null) {
+      pastAssignees = Lists.newArrayList();
+    }
+    String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);
+    if (assigneeValue != null) {
+      Optional<Account.Id> parsedAssignee;
+      if (assigneeValue.equals("")) {
+        // Empty footer found, assignee deleted
+        parsedAssignee = Optional.empty();
+      } else {
+        PersonIdent ident = RawParseUtils.parsePersonIdent(assigneeValue);
+        parsedAssignee = Optional.ofNullable(noteUtil.parseIdent(ident, id));
+      }
+      if (assignee == null) {
+        assignee = parsedAssignee;
+      }
+      if (parsedAssignee.isPresent()) {
+        pastAssignees.add(parsedAssignee.get());
+      }
+    }
+  }
+
   private void parseTag(ChangeNotesCommit commit)
       throws ConfigInvalidException {
     tag = null;
@@ -480,12 +545,21 @@
     } else if (statusLines.size() > 1) {
       throw expectedOneFooter(FOOTER_STATUS, statusLines);
     }
-    Optional<Change.Status> status = Enums.getIfPresent(
-        Change.Status.class, statusLines.get(0).toUpperCase());
-    if (!status.isPresent()) {
+    Change.Status status = Enums.getIfPresent(
+        Change.Status.class, statusLines.get(0).toUpperCase()).orNull();
+    if (status == null) {
       throw invalidFooter(FOOTER_STATUS, statusLines.get(0));
     }
-    return status.get();
+    // All approvals after MERGED and before the next status change get the
+    // postSubmit bit. (Currently the state can't change from MERGED to
+    // something else, but just in case.)
+    if (status == Change.Status.MERGED) {
+      for (PatchSetApproval psa : bufferedApprovals) {
+        psa.setPostSubmit(true);
+      }
+    }
+    bufferedApprovals.clear();
+    return status;
   }
 
   private PatchSet.Id parsePatchSetId(ChangeNotesCommit commit)
@@ -509,17 +583,19 @@
     }
     String withParens = psIdLine.substring(s + 1);
     if (withParens.startsWith("(") && withParens.endsWith(")")) {
-      Optional<PatchSetState> state = Enums.getIfPresent(PatchSetState.class,
-          withParens.substring(1, withParens.length() - 1).toUpperCase());
-      if (state.isPresent()) {
-        return state.get();
+      PatchSetState state = Enums.getIfPresent(PatchSetState.class,
+              withParens.substring(1, withParens.length() - 1).toUpperCase())
+          .orNull();
+      if (state != null) {
+        return state;
       }
     }
     throw invalidFooter(FOOTER_PATCH_SET, psIdLine);
   }
 
   private void parseChangeMessage(PatchSet.Id psId,
-      Account.Id accountId, ChangeNotesCommit commit, Timestamp ts) {
+      Account.Id accountId, Account.Id realAccountId,
+      ChangeNotesCommit commit, Timestamp ts) {
     byte[] raw = commit.getRawBuffer();
     int size = raw.length;
     Charset enc = RawParseUtils.parseEncoding(raw);
@@ -568,11 +644,10 @@
         changeMessageStart, changeMessageEnd + 1);
     ChangeMessage changeMessage = new ChangeMessage(
         new ChangeMessage.Key(psId.getParentKey(), commit.name()),
-        accountId,
-        ts,
-        psId);
+        accountId, ts, psId);
     changeMessage.setMessage(changeMsgString);
     changeMessage.setTag(tag);
+    changeMessage.setRealAuthor(realAccountId);
     changeMessagesByPatchSet.put(psId, changeMessage);
     allChangeMessages.add(changeMessage);
   }
@@ -582,49 +657,67 @@
     ObjectReader reader = walk.getObjectReader();
     ChangeNotesCommit tipCommit = walk.parseCommit(tip);
     revisionNoteMap = RevisionNoteMap.parse(
-        noteUtil, id, reader, NoteMap.read(reader, tipCommit), false);
-    Map<RevId, RevisionNote> rns = revisionNoteMap.revisionNotes;
+        noteUtil, id, reader, NoteMap.read(reader, tipCommit),
+        PatchLineComment.Status.PUBLISHED);
+    Map<RevId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
 
-    for (Map.Entry<RevId, RevisionNote> e : rns.entrySet()) {
-      for (PatchLineComment plc : e.getValue().comments) {
-        comments.put(e.getKey(), plc);
+    for (Map.Entry<RevId, ChangeRevisionNote> e : rns.entrySet()) {
+      for (Comment c : e.getValue().getComments()) {
+        comments.put(e.getKey(), c);
       }
     }
 
     for (PatchSet ps : patchSets.values()) {
-      RevisionNote rn = rns.get(ps.getRevision());
-      if (rn != null && rn.pushCert != null) {
-        ps.setPushCertificate(rn.pushCert);
+      ChangeRevisionNote rn = rns.get(ps.getRevision());
+      if (rn != null && rn.getPushCert() != null) {
+        ps.setPushCertificate(rn.getPushCert());
       }
     }
   }
 
   private void parseApproval(PatchSet.Id psId, Account.Id accountId,
-      Timestamp ts, String line) throws ConfigInvalidException {
+      Account.Id realAccountId, Timestamp ts, String line)
+      throws ConfigInvalidException {
     if (accountId == null) {
       throw parseException(
           "patch set %s requires an identified user as uploader", psId.get());
     }
+    PatchSetApproval psa;
     if (line.startsWith("-")) {
-      parseRemoveApproval(psId, accountId, line);
+      psa = parseRemoveApproval(psId, accountId, realAccountId, ts, line);
     } else {
-      parseAddApproval(psId, accountId, ts, line);
+      psa = parseAddApproval(psId, accountId, realAccountId, ts, line);
     }
+    bufferedApprovals.add(psa);
   }
 
-  private void parseAddApproval(PatchSet.Id psId, Account.Id committerId,
-      Timestamp ts, String line) throws ConfigInvalidException {
-    Account.Id accountId;
+  private PatchSetApproval parseAddApproval(PatchSet.Id psId,
+      Account.Id committerId, Account.Id realAccountId, Timestamp ts,
+      String line)
+      throws ConfigInvalidException {
+    // There are potentially 3 accounts involved here:
+    //  1. The account from the commit, which is the effective IdentifiedUser
+    //     that produced the update.
+    //  2. The account in the label footer itself, which is used during submit
+    //     to copy other users' labels to a new patch set.
+    //  3. The account in the Real-user footer, indicating that the whole
+    //     update operation was executed by this user on behalf of the effective
+    //     user.
+    Account.Id effectiveAccountId;
     String labelVoteStr;
     int s = line.indexOf(' ');
     if (s > 0) {
+      // Account in the label line (2) becomes the effective ID of the
+      // approval. If there is a real user (3) different from the commit user
+      // (2), we actually don't store that anywhere in this case; it's more
+      // important to record that the real user (3) actually initiated submit.
       labelVoteStr = line.substring(0, s);
       PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
       checkFooter(ident != null, FOOTER_LABEL, line);
-      accountId = noteUtil.parseIdent(ident, id);
+      effectiveAccountId = noteUtil.parseIdent(ident, id);
     } else {
       labelVoteStr = line;
-      accountId = committerId;
+      effectiveAccountId = committerId;
     }
 
     LabelVote l;
@@ -637,39 +730,44 @@
       throw pe;
     }
 
-    Entry<String, String> label = Maps.immutableEntry(l.label(), tag);
-    Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>> curr =
-        getApprovalsTableIfNoVotePresent(psId, accountId, label);
-    if (curr != null) {
-      PatchSetApproval psa = new PatchSetApproval(
-          new PatchSetApproval.Key(
-              psId,
-              accountId,
-              new LabelId(l.label())),
-          l.value(),
-          ts);
-      psa.setTag(tag);
-      curr.put(accountId, label, Optional.of(psa));
+    PatchSetApproval psa = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            psId,
+            effectiveAccountId,
+            new LabelId(l.label())),
+        l.value(),
+        ts);
+    psa.setTag(tag);
+    if (!Objects.equals(realAccountId, committerId)) {
+      psa.setRealAccountId(realAccountId);
     }
+    ApprovalKey k =
+        ApprovalKey.create(psId, effectiveAccountId, l.label());
+    if (!approvals.containsKey(k)) {
+      approvals.put(k, psa);
+    }
+    return psa;
   }
 
-  private void parseRemoveApproval(PatchSet.Id psId, Account.Id committerId,
+  private PatchSetApproval parseRemoveApproval(PatchSet.Id psId,
+      Account.Id committerId, Account.Id realAccountId, Timestamp ts,
       String line) throws ConfigInvalidException {
-    Account.Id accountId;
-    Entry<String, String> label;
+    // See comments in parseAddApproval about the various users involved.
+    Account.Id effectiveAccountId;
+    String label;
     int s = line.indexOf(' ');
     if (s > 0) {
-      label = Maps.immutableEntry(line.substring(1, s), tag);
+      label = line.substring(1, s);
       PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
       checkFooter(ident != null, FOOTER_LABEL, line);
-      accountId = noteUtil.parseIdent(ident, id);
+      effectiveAccountId = noteUtil.parseIdent(ident, id);
     } else {
-      label = Maps.immutableEntry(line.substring(1), tag);
-      accountId = committerId;
+      label = line.substring(1);
+      effectiveAccountId = committerId;
     }
 
     try {
-      LabelType.checkNameInternal(label.getKey());
+      LabelType.checkNameInternal(label);
     } catch (IllegalArgumentException e) {
       ConfigInvalidException pe =
           parseException("invalid %s: %s", FOOTER_LABEL, line);
@@ -677,36 +775,27 @@
       throw pe;
     }
 
-    Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>> curr =
-        getApprovalsTableIfNoVotePresent(psId, accountId, label);
-    if (curr != null) {
-      curr.put(accountId, label, Optional.<PatchSetApproval> absent());
+    // Store an actual 0-vote approval in the map for a removed approval, for
+    // several reasons:
+    //  - This is closer to the ReviewDb representation, which leads to less
+    //    confusion and special-casing of NoteDb.
+    //  - More importantly, ApprovalCopier needs an actual approval in order to
+    //    block copying an earlier approval over a later delete.
+    PatchSetApproval remove = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            psId,
+            effectiveAccountId,
+            new LabelId(label)),
+        (short) 0,
+        ts);
+    if (!Objects.equals(realAccountId, committerId)) {
+      remove.setRealAccountId(realAccountId);
     }
-  }
-
-  private Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>>
-      getApprovalsTableIfNoVotePresent(PatchSet.Id psId, Account.Id accountId,
-        Entry<String, String> label) {
-
-    Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>> curr =
-        approvals.get(psId);
-    if (curr != null) {
-      if (curr.contains(accountId, label)) {
-        return null;
-      }
-    } else {
-      curr = Tables.newCustomTable(
-          Maps.<Account.Id, Map<Entry<String, String>, Optional<PatchSetApproval>>>
-              newHashMapWithExpectedSize(2),
-          new Supplier<Map<Entry<String, String>, Optional<PatchSetApproval>>>() {
-            @Override
-            public Map<Entry<String, String>, Optional<PatchSetApproval>> get() {
-              return new LinkedHashMap<>();
-            }
-          });
-      approvals.put(psId, curr);
+    ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, label);
+    if (!approvals.containsKey(k)) {
+      approvals.put(k, remove);
     }
-    return curr;
+    return remove;
   }
 
   private void parseSubmitRecords(List<String> lines)
@@ -720,10 +809,9 @@
         submitRecords.add(rec);
         int s = line.indexOf(' ');
         String statusStr = s >= 0 ? line.substring(0, s) : line;
-        Optional<SubmitRecord.Status> status =
-            Enums.getIfPresent(SubmitRecord.Status.class, statusStr);
-        checkFooter(status.isPresent(), FOOTER_SUBMITTED_WITH, line);
-        rec.status = status.get();
+        rec.status =
+            Enums.getIfPresent(SubmitRecord.Status.class, statusStr).orNull();
+        checkFooter(rec.status != null, FOOTER_SUBMITTED_WITH, line);
         if (s >= 0) {
           rec.errorMessage = line.substring(s);
         }
@@ -735,10 +823,9 @@
         }
         rec.labels.add(label);
 
-        Optional<SubmitRecord.Label.Status> status = Enums.getIfPresent(
-            SubmitRecord.Label.Status.class, line.substring(0, c));
-        checkFooter(status.isPresent(), FOOTER_SUBMITTED_WITH, line);
-        label.status = status.get();
+        label.status = Enums.getIfPresent(
+            SubmitRecord.Label.Status.class, line.substring(0, c)).orNull();
+        checkFooter(label.status != null, FOOTER_SUBMITTED_WITH, line);
         int c2 = line.indexOf(": ", c + 2);
         if (c2 >= 0) {
           label.label = line.substring(c + 2, c2);
@@ -787,25 +874,20 @@
       Table.Cell<Account.Id, ReviewerStateInternal, Timestamp> e = rit.next();
       if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
         rit.remove();
-        for (Table<Account.Id, ?, ?> curr : approvals.values()) {
-          curr.rowKeySet().remove(e.getRowKey());
-        }
       }
     }
   }
 
-  private void updatePatchSetStates() throws ConfigInvalidException {
-    for (PatchSet ps : patchSets.values()) {
+  private void updatePatchSetStates() {
+    Set<PatchSet.Id> missing = new TreeSet<>(ReviewDbUtil.intKeyOrdering());
+    for (Iterator<PatchSet> it = patchSets.values().iterator();
+        it.hasNext();) {
+      PatchSet ps = it.next();
       if (ps.getRevision().equals(PARTIAL_PATCH_SET)) {
-        throw parseException("No %s found for patch set %s",
-            FOOTER_COMMIT, ps.getPatchSetId());
+        missing.add(ps.getId());
+        it.remove();
       }
     }
-    if (patchSetStates.isEmpty()) {
-      return;
-    }
-
-    boolean deleted = false;
     for (Map.Entry<PatchSet.Id, PatchSetState> e : patchSetStates.entrySet()) {
       switch (e.getValue()) {
         case PUBLISHED:
@@ -813,7 +895,6 @@
           break;
 
         case DELETED:
-          deleted = true;
           patchSets.remove(e.getKey());
           break;
 
@@ -825,35 +906,48 @@
           break;
       }
     }
-    if (!deleted) {
-      return;
-    }
 
     // Post-process other collections to remove items corresponding to deleted
-    // patch sets. This is safer than trying to prevent insertion, as it will
-    // also filter out items racily added after the patch set was deleted.
+    // (or otherwise missing) patch sets. This is safer than trying to prevent
+    // insertion, as it will also filter out items racily added after the patch
+    // set was deleted.
     NavigableSet<PatchSet.Id> all = patchSets.navigableKeySet();
     if (!all.isEmpty()) {
       currentPatchSetId = all.last();
     } else {
       currentPatchSetId = null;
     }
-    approvals.keySet().retainAll(all);
     changeMessagesByPatchSet.keys().retainAll(all);
 
-    for (Iterator<ChangeMessage> it = allChangeMessages.iterator();
-        it.hasNext();) {
-      if (!all.contains(it.next().getPatchSetId())) {
+    int pruned = pruneEntitiesForMissingPatchSets(
+        allChangeMessages, ChangeMessage::getPatchSetId, missing);
+    pruned += pruneEntitiesForMissingPatchSets(
+        comments.values(), c -> new PatchSet.Id(id, c.key.patchSetId), missing);
+    pruned += pruneEntitiesForMissingPatchSets(
+        approvals.values(), PatchSetApproval::getPatchSetId, missing);
+
+    if (!missing.isEmpty()) {
+      log.warn(
+          "ignoring {} additional entities due to missing patch sets: {}",
+          pruned, missing);
+    }
+  }
+
+  private <T> int pruneEntitiesForMissingPatchSets(
+      Iterable<T> ents, Function<T, PatchSet.Id> psIdFunc,
+      Set<PatchSet.Id> missing) {
+    int pruned = 0;
+    for (Iterator<T> it = ents.iterator(); it.hasNext();) {
+      PatchSet.Id psId = psIdFunc.apply(it.next());
+      if (!patchSets.containsKey(psId)) {
+        pruned++;
+        missing.add(psId);
         it.remove();
+      } else if (deletedPatchSets.contains(psId)) {
+        it.remove(); // Not an error we need to report, don't increment pruned.
       }
     }
-    for (Iterator<PatchLineComment> it = comments.values().iterator();
-        it.hasNext();) {
-      PatchSet.Id psId = it.next().getKey().getParentKey().getParentKey();
-      if (!all.contains(psId)) {
-        it.remove();
-      }
-    }
+    return pruned;
   }
 
   private void checkMandatoryFooters() throws ConfigInvalidException {
@@ -868,13 +962,8 @@
       missing.add(FOOTER_SUBJECT);
     }
     if (!missing.isEmpty()) {
-      throw parseException("Missing footers: " + Joiner.on(", ")
-          .join(Lists.transform(missing, new Function<FooterKey, String>() {
-            @Override
-            public String apply(FooterKey input) {
-              return input.getName();
-            }
-          })));
+      throw parseException("Missing footers: "
+          + missing.stream().map(FooterKey::getName).collect(joining(", ")));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 988184f..e0f7920 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -21,7 +21,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.Multimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
@@ -29,11 +28,10 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 
@@ -59,16 +57,17 @@
     return new AutoValue_ChangeNotesState(
         change.getId(),
         null,
-        ImmutableSet.<String>of(),
-        ImmutableSortedMap.<PatchSet.Id, PatchSet>of(),
-        ImmutableListMultimap.<PatchSet.Id, PatchSetApproval>of(),
+        ImmutableSet.of(),
+        ImmutableSet.of(),
+        ImmutableList.of(),
+        ImmutableList.of(),
         ReviewerSet.empty(),
-        ImmutableList.<Account.Id>of(),
-        ImmutableList.<ReviewerStatusUpdate>of(),
-        ImmutableList.<SubmitRecord>of(),
-        ImmutableList.<ChangeMessage>of(),
-        ImmutableListMultimap.<PatchSet.Id, ChangeMessage>of(),
-        ImmutableListMultimap.<RevId, PatchLineComment>of());
+        ImmutableList.of(),
+        ImmutableList.of(),
+        ImmutableList.of(),
+        ImmutableList.of(),
+        ImmutableListMultimap.of(),
+        ImmutableListMultimap.of());
   }
 
   static ChangeNotesState create(
@@ -83,7 +82,9 @@
       @Nullable String topic,
       @Nullable String originalSubject,
       @Nullable String submissionId,
+      @Nullable Account.Id assignee,
       @Nullable Change.Status status,
+      @Nullable Set<Account.Id> pastAssignees,
       @Nullable Set<String> hashtags,
       Map<PatchSet.Id, PatchSet> patchSets,
       Multimap<PatchSet.Id, PatchSetApproval> approvals,
@@ -93,7 +94,7 @@
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> allChangeMessages,
       Multimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet,
-      Multimap<RevId, PatchLineComment> publishedComments) {
+      Multimap<RevId, Comment> publishedComments) {
     if (hashtags == null) {
       hashtags = ImmutableSet.of();
     }
@@ -110,10 +111,12 @@
             topic,
             originalSubject,
             submissionId,
+            assignee,
             status),
+        ImmutableSet.copyOf(pastAssignees),
         ImmutableSet.copyOf(hashtags),
-        ImmutableSortedMap.copyOf(patchSets, ReviewDbUtil.intKeyOrdering()),
-        ImmutableListMultimap.copyOf(approvals),
+        ImmutableList.copyOf(patchSets.entrySet()),
+        ImmutableList.copyOf(approvals.entries()),
         reviewers,
         ImmutableList.copyOf(allPastReviewers),
         ImmutableList.copyOf(reviewerUpdates),
@@ -144,6 +147,7 @@
     @Nullable abstract String topic();
     @Nullable abstract String originalSubject();
     @Nullable abstract String submissionId();
+    @Nullable abstract Account.Id assignee();
     // TODO(dborowitz): Use a sensible default other than null
     @Nullable abstract Change.Status status();
   }
@@ -153,9 +157,10 @@
   @Nullable abstract ChangeColumns columns();
 
   // Other related to this Change.
+  abstract ImmutableSet<Account.Id> pastAssignees();
   abstract ImmutableSet<String> hashtags();
-  abstract ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets();
-  abstract ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals();
+  abstract ImmutableList<Map.Entry<PatchSet.Id, PatchSet>> patchSets();
+  abstract ImmutableList<Map.Entry<PatchSet.Id, PatchSetApproval>> approvals();
 
   abstract ReviewerSet reviewers();
   abstract ImmutableList<Account.Id> allPastReviewers();
@@ -165,7 +170,7 @@
   abstract ImmutableList<ChangeMessage> allChangeMessages();
   abstract ImmutableListMultimap<PatchSet.Id, ChangeMessage>
       changeMessagesByPatchSet();
-  abstract ImmutableListMultimap<RevId, PatchLineComment> publishedComments();
+  abstract ImmutableListMultimap<RevId, Comment> publishedComments();
 
   void copyColumnsTo(Change change) {
     ChangeColumns c = checkNotNull(columns());
@@ -179,6 +184,7 @@
     change.setLastUpdatedOn(c.lastUpdatedOn());
     change.setOwner(c.owner());
     change.setSubmissionId(c.submissionId());
+    change.setAssignee(c.assignee());
 
     if (!patchSets().isEmpty()) {
       change.setCurrentPatchSet(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
deleted file mode 100644
index 08acbad..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
+++ /dev/null
@@ -1,1060 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
-import com.google.common.base.Splitter;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableMultimap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Multimap;
-import com.google.common.collect.Ordering;
-import com.google.common.collect.Sets;
-import com.google.common.collect.Table;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.FormatUtil;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.PatchLineCommentsUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.git.ChainedReceiveCommands;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.OpenRepo;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.AtomicUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.io.PrintWriter;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-public class ChangeRebuilderImpl extends ChangeRebuilder {
-  private static final Logger log =
-      LoggerFactory.getLogger(ChangeRebuilderImpl.class);
-
-  /**
-   * The maximum amount of time between the ReviewDb timestamp of the first and
-   * last events batched together into a single NoteDb update.
-   * <p>
-   * Used to account for the fact that different records with their own
-   * timestamps (e.g. {@link PatchSetApproval} and {@link ChangeMessage})
-   * historically didn't necessarily use the same timestamp, and tended to call
-   * {@code System.currentTimeMillis()} independently.
-   */
-  static final long MAX_WINDOW_MS = SECONDS.toMillis(3);
-
-  /**
-   * The maximum amount of time between two consecutive events to consider them
-   * to be in the same batch.
-   */
-  private static final long MAX_DELTA_MS = SECONDS.toMillis(1);
-
-  private final AccountCache accountCache;
-  private final ChangeDraftUpdate.Factory draftUpdateFactory;
-  private final ChangeNoteUtil changeNoteUtil;
-  private final ChangeUpdate.Factory updateFactory;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final NotesMigration migration;
-  private final PatchListCache patchListCache;
-  private final PersonIdent serverIdent;
-  private final ProjectCache projectCache;
-  private final String anonymousCowardName;
-
-  @Inject
-  ChangeRebuilderImpl(SchemaFactory<ReviewDb> schemaFactory,
-      AccountCache accountCache,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
-      ChangeNoteUtil changeNoteUtil,
-      ChangeUpdate.Factory updateFactory,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      NotesMigration migration,
-      PatchListCache patchListCache,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @Nullable ProjectCache projectCache,
-      @AnonymousCowardName String anonymousCowardName) {
-    super(schemaFactory);
-    this.accountCache = accountCache;
-    this.draftUpdateFactory = draftUpdateFactory;
-    this.changeNoteUtil = changeNoteUtil;
-    this.updateFactory = updateFactory;
-    this.updateManagerFactory = updateManagerFactory;
-    this.migration = migration;
-    this.patchListCache = patchListCache;
-    this.serverIdent = serverIdent;
-    this.projectCache = projectCache;
-    this.anonymousCowardName = anonymousCowardName;
-  }
-
-  @Override
-  public Result rebuild(ReviewDb db, Change.Id changeId)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException {
-    db = ReviewDbUtil.unwrapDb(db);
-    Change change = db.changes().get(changeId);
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-    try (NoteDbUpdateManager manager =
-        updateManagerFactory.create(change.getProject())) {
-      buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
-      return execute(db, changeId, manager);
-    }
-  }
-
-  private static class AbortUpdateException extends OrmRuntimeException {
-    private static final long serialVersionUID = 1L;
-
-    AbortUpdateException() {
-      super("aborted");
-    }
-  }
-
-  private static class ConflictingUpdateException extends OrmRuntimeException {
-    private static final long serialVersionUID = 1L;
-
-    ConflictingUpdateException(Change change, String expectedNoteDbState) {
-      super(String.format(
-          "Expected change %s to have noteDbState %s but was %s",
-          change.getId(), expectedNoteDbState, change.getNoteDbState()));
-    }
-  }
-
-  @Override
-  public Result rebuild(NoteDbUpdateManager manager,
-      ChangeBundle bundle) throws NoSuchChangeException, IOException,
-      OrmException, ConfigInvalidException {
-    Change change = new Change(bundle.getChange());
-    buildUpdates(manager, bundle);
-    return manager.stageAndApplyDelta(change);
-  }
-
-  @Override
-  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
-      throws NoSuchChangeException, IOException, OrmException {
-    db = ReviewDbUtil.unwrapDb(db);
-    Change change = db.changes().get(changeId);
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-    NoteDbUpdateManager manager =
-        updateManagerFactory.create(change.getProject());
-    buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
-    manager.stage();
-    return manager;
-  }
-
-  @Override
-  public Result execute(ReviewDb db, Change.Id changeId,
-      NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
-      IOException {
-    db = ReviewDbUtil.unwrapDb(db);
-    Change change = db.changes().get(changeId);
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    final String oldNoteDbState = change.getNoteDbState();
-    Result r = manager.stageAndApplyDelta(change);
-    final String newNoteDbState = change.getNoteDbState();
-    try {
-      db.changes().atomicUpdate(changeId, new AtomicUpdate<Change>() {
-        @Override
-        public Change update(Change change) {
-          String currNoteDbState = change.getNoteDbState();
-          if (Objects.equals(currNoteDbState, newNoteDbState)) {
-            // Another thread completed the same rebuild we were about to.
-            throw new AbortUpdateException();
-          } else if (!Objects.equals(oldNoteDbState, currNoteDbState)) {
-            // Another thread updated the state to something else.
-            throw new ConflictingUpdateException(change, oldNoteDbState);
-          }
-          change.setNoteDbState(newNoteDbState);
-          return change;
-        }
-      });
-    } catch (ConflictingUpdateException e) {
-      // Rethrow as an OrmException so the caller knows to use staged results.
-      // Strictly speaking they are not completely up to date, but result we
-      // send to the caller is the same as if this rebuild had executed before
-      // the other thread.
-      throw new OrmException(e.getMessage());
-    } catch (AbortUpdateException e) {
-      if (NoteDbChangeState.parse(changeId, newNoteDbState).isUpToDate(
-          manager.getChangeRepo().cmds.getRepoRefCache(),
-          manager.getAllUsersRepo().cmds.getRepoRefCache())) {
-        // If the state in ReviewDb matches NoteDb at this point, it means
-        // another thread successfully completed this rebuild. It's ok to not
-        // execute the update in this case, since the object referenced in the
-        // Result was flushed to the repo by whatever thread won the race.
-        return r;
-      }
-      // If the state doesn't match, that means another thread attempted this
-      // rebuild, but failed. Fall through and try to update the ref again.
-    }
-    if (migration.failChangeWrites()) {
-      // Don't even attempt to execute if read-only, it would fail anyway. But
-      // do throw an exception to the caller so they know to use the staged
-      // results instead of reading from the repo.
-      throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
-    }
-    manager.execute();
-    return r;
-  }
-
-  @Override
-  public boolean rebuildProject(ReviewDb db,
-      ImmutableMultimap<Project.NameKey, Change.Id> allChanges,
-      Project.NameKey project, Repository allUsersRepo)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException {
-    checkArgument(allChanges.containsKey(project));
-    boolean ok = true;
-    ProgressMonitor pm = new TextProgressMonitor(new PrintWriter(System.out));
-    pm.beginTask(
-        FormatUtil.elide(project.get(), 50), allChanges.get(project).size());
-    try (NoteDbUpdateManager manager = updateManagerFactory.create(project);
-        ObjectInserter allUsersInserter = allUsersRepo.newObjectInserter();
-        RevWalk allUsersRw = new RevWalk(allUsersInserter.newReader())) {
-      manager.setAllUsersRepo(allUsersRepo, allUsersRw, allUsersInserter,
-          new ChainedReceiveCommands(allUsersRepo));
-      for (Change.Id changeId : allChanges.get(project)) {
-        try {
-          buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
-        } catch (NoPatchSetsException e) {
-          log.warn(e.getMessage());
-        } catch (Throwable t) {
-          log.error("Failed to rebuild change " + changeId, t);
-          ok = false;
-        }
-        pm.update(1);
-      }
-      manager.execute();
-    } finally {
-      pm.endTask();
-    }
-    return ok;
-  }
-
-  private void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
-      throws IOException, OrmException {
-    manager.setCheckExpectedState(false);
-    Change change = new Change(bundle.getChange());
-    if (bundle.getPatchSets().isEmpty()) {
-      throw new NoPatchSetsException(change.getId());
-    }
-
-    PatchSet.Id currPsId = change.currentPatchSetId();
-    // We will rebuild all events, except for draft comments, in buckets based
-    // on author and timestamp.
-    List<Event> events = new ArrayList<>();
-    Multimap<Account.Id, PatchLineCommentEvent> draftCommentEvents =
-        ArrayListMultimap.create();
-
-    events.addAll(getHashtagsEvents(change, manager));
-
-    // Delete ref only after hashtags have been read
-    deleteChangeMetaRef(change, manager.getChangeRepo().cmds);
-    deleteDraftRefs(change, manager.getAllUsersRepo());
-
-    Integer minPsNum = getMinPatchSetNum(bundle);
-    Set<PatchSet.Id> psIds =
-        Sets.newHashSetWithExpectedSize(bundle.getPatchSets().size());
-
-    for (PatchSet ps : bundle.getPatchSets()) {
-      if (ps.getId().get() > currPsId.get()) {
-        log.info(
-            "Skipping patch set {}, which is higher than current patch set {}",
-            ps.getId(), currPsId);
-        continue;
-      }
-      psIds.add(ps.getId());
-      events.add(new PatchSetEvent(
-          change, ps, manager.getChangeRepo().rw));
-      for (PatchLineComment c : getPatchLineComments(bundle, ps)) {
-        PatchLineCommentEvent e =
-            new PatchLineCommentEvent(c, change, ps, patchListCache);
-        if (c.getStatus() == Status.PUBLISHED) {
-          events.add(e);
-        } else {
-          draftCommentEvents.put(c.getAuthor(), e);
-        }
-      }
-    }
-
-    for (PatchSetApproval psa : bundle.getPatchSetApprovals()) {
-      if (psIds.contains(psa.getPatchSetId())) {
-        events.add(new ApprovalEvent(psa, change.getCreatedOn()));
-      }
-    }
-
-    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> r :
-        bundle.getReviewers().asTable().cellSet()) {
-      events.add(new ReviewerEvent(r, change.getCreatedOn()));
-    }
-
-    Change noteDbChange = new Change(null, null, null, null, null);
-    for (ChangeMessage msg : bundle.getChangeMessages()) {
-      if (msg.getPatchSetId() == null || psIds.contains(msg.getPatchSetId())) {
-        events.add(
-            new ChangeMessageEvent(msg, noteDbChange, change.getCreatedOn()));
-      }
-    }
-
-    sortAndFillEvents(change, noteDbChange, events, minPsNum);
-
-    EventList<Event> el = new EventList<>();
-    for (Event e : events) {
-      if (!el.canAdd(e)) {
-        flushEventsToUpdate(manager, el, change);
-        checkState(el.canAdd(e));
-      }
-      el.add(e);
-    }
-    flushEventsToUpdate(manager, el, change);
-
-    EventList<PatchLineCommentEvent> plcel = new EventList<>();
-    for (Account.Id author : draftCommentEvents.keys()) {
-      for (PatchLineCommentEvent e :
-          EVENT_ORDER.sortedCopy(draftCommentEvents.get(author))) {
-        if (!plcel.canAdd(e)) {
-          flushEventsToDraftUpdate(manager, plcel, change);
-          checkState(plcel.canAdd(e));
-        }
-        plcel.add(e);
-      }
-      flushEventsToDraftUpdate(manager, plcel, change);
-    }
-  }
-
-  private static Integer getMinPatchSetNum(ChangeBundle bundle) {
-    Integer minPsNum = null;
-    for (PatchSet ps : bundle.getPatchSets()) {
-      int n = ps.getId().get();
-      if (minPsNum == null || n < minPsNum) {
-        minPsNum = n;
-      }
-    }
-    return minPsNum;
-  }
-
-  private static List<PatchLineComment> getPatchLineComments(ChangeBundle bundle,
-      final PatchSet ps) {
-    return FluentIterable.from(bundle.getPatchLineComments())
-        .filter(new Predicate<PatchLineComment>() {
-          @Override
-          public boolean apply(PatchLineComment in) {
-            return in.getPatchSetId().equals(ps.getId());
-          }
-        }).toSortedList(PatchLineCommentsUtil.PLC_ORDER);
-  }
-
-  private void sortAndFillEvents(Change change, Change noteDbChange,
-      List<Event> events, Integer minPsNum) {
-    Collections.sort(events, EVENT_ORDER);
-    events.add(new FinalUpdatesEvent(change, noteDbChange));
-
-    // Ensure the first event in the list creates the change, setting the author
-    // and any required footers.
-    Event first = events.get(0);
-    if (first instanceof PatchSetEvent && change.getOwner().equals(first.who)) {
-      ((PatchSetEvent) first).createChange = true;
-    } else {
-      events.add(0, new CreateChangeEvent(change, minPsNum));
-    }
-
-    // Fill in any missing patch set IDs using the latest patch set of the
-    // change at the time of the event, because NoteDb can't represent actions
-    // with no associated patch set ID. This workaround is as if a user added a
-    // ChangeMessage on the change by replying from the latest patch set.
-    //
-    // Start with the first patch set that actually exists. If there are no
-    // patch sets at all, minPsNum will be null, so just bail and use 1 as the
-    // patch set ID. The corresponding patch set won't exist, but this change is
-    // probably corrupt anyway, as deleting the last draft patch set should have
-    // deleted the whole change.
-    int ps = firstNonNull(minPsNum, 1);
-    for (Event e : events) {
-      if (e.psId == null) {
-        e.psId = new PatchSet.Id(change.getId(), ps);
-      } else {
-        ps = Math.max(ps, e.psId.get());
-      }
-    }
-  }
-
-  private void flushEventsToUpdate(NoteDbUpdateManager manager,
-      EventList<Event> events, Change change) throws OrmException, IOException {
-    if (events.isEmpty()) {
-      return;
-    }
-    Comparator<String> labelNameComparator;
-    if (projectCache != null) {
-      labelNameComparator = projectCache.get(change.getProject())
-          .getLabelTypes().nameComparator();
-    } else {
-      // No project cache available, bail and use natural ordering; there's no
-      // semantic difference anyway difference.
-      labelNameComparator = Ordering.natural();
-    }
-    ChangeUpdate update = updateFactory.create(
-        change,
-        events.getAccountId(),
-        events.newAuthorIdent(),
-        events.getWhen(),
-        labelNameComparator);
-    update.setAllowWriteToNewRef(true);
-    update.setPatchSetId(events.getPatchSetId());
-    update.setTag(events.getTag());
-    for (Event e : events) {
-      e.apply(update);
-    }
-    manager.add(update);
-    events.clear();
-  }
-
-  private void flushEventsToDraftUpdate(NoteDbUpdateManager manager,
-      EventList<PatchLineCommentEvent> events, Change change)
-      throws OrmException {
-    if (events.isEmpty()) {
-      return;
-    }
-    ChangeDraftUpdate update = draftUpdateFactory.create(
-        change,
-        events.getAccountId(),
-        events.newAuthorIdent(),
-        events.getWhen());
-    update.setPatchSetId(events.getPatchSetId());
-    for (PatchLineCommentEvent e : events) {
-      e.applyDraft(update);
-    }
-    manager.add(update);
-    events.clear();
-  }
-
-  private List<HashtagsEvent> getHashtagsEvents(Change change,
-      NoteDbUpdateManager manager) throws IOException {
-    String refName = changeMetaRef(change.getId());
-    Optional<ObjectId> old = manager.getChangeRepo().getObjectId(refName);
-    if (!old.isPresent()) {
-      return Collections.emptyList();
-    }
-
-    RevWalk rw = manager.getChangeRepo().rw;
-    List<HashtagsEvent> events = new ArrayList<>();
-    rw.reset();
-    rw.markStart(rw.parseCommit(old.get()));
-    for (RevCommit commit : rw) {
-      Account.Id authorId;
-      try {
-        authorId =
-            changeNoteUtil.parseIdent(commit.getAuthorIdent(), change.getId());
-      } catch (ConfigInvalidException e) {
-        continue; // Corrupt data, no valid hashtags in this commit.
-      }
-      PatchSet.Id psId = parsePatchSetId(change, commit);
-      Set<String> hashtags = parseHashtags(commit);
-      if (authorId == null || psId == null || hashtags == null) {
-        continue;
-      }
-
-      Timestamp commitTime =
-          new Timestamp(commit.getCommitterIdent().getWhen().getTime());
-      events.add(new HashtagsEvent(psId, authorId, commitTime, hashtags,
-            change.getCreatedOn()));
-    }
-    return events;
-  }
-
-  private Set<String> parseHashtags(RevCommit commit) {
-    List<String> hashtagsLines = commit.getFooterLines(FOOTER_HASHTAGS);
-    if (hashtagsLines.isEmpty() || hashtagsLines.size() > 1) {
-      return null;
-    }
-
-    if (hashtagsLines.get(0).isEmpty()) {
-      return ImmutableSet.of();
-    }
-    return Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
-  }
-
-  private PatchSet.Id parsePatchSetId(Change change, RevCommit commit) {
-    List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
-    if (psIdLines.size() != 1) {
-      return null;
-    }
-    Integer psId = Ints.tryParse(psIdLines.get(0));
-    if (psId == null) {
-      return null;
-    }
-    return new PatchSet.Id(change.getId(), psId);
-  }
-
-  private void deleteChangeMetaRef(Change change, ChainedReceiveCommands cmds)
-      throws IOException {
-    String refName = changeMetaRef(change.getId());
-    Optional<ObjectId> old = cmds.get(refName);
-    if (old.isPresent()) {
-      cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), refName));
-    }
-  }
-
-  private void deleteDraftRefs(Change change, OpenRepo allUsersRepo)
-      throws IOException {
-    for (Ref r : allUsersRepo.repo.getRefDatabase()
-        .getRefs(RefNames.refsDraftCommentsPrefix(change.getId())).values()) {
-      allUsersRepo.cmds.add(
-          new ReceiveCommand(r.getObjectId(), ObjectId.zeroId(), r.getName()));
-    }
-  }
-
-  private static final Ordering<Event> EVENT_ORDER = new Ordering<Event>() {
-    @Override
-    public int compare(Event a, Event b) {
-      return ComparisonChain.start()
-          .compare(a.when, b.when)
-          .compareTrueFirst(isPatchSet(a), isPatchSet(b))
-          .compareTrueFirst(a.predatesChange, b.predatesChange)
-          .compare(a.who, b.who, ReviewDbUtil.intKeyOrdering())
-          .compare(a.psId, b.psId, ReviewDbUtil.intKeyOrdering().nullsLast())
-          .result();
-    }
-
-    private boolean isPatchSet(Event e) {
-      return e instanceof PatchSetEvent;
-    }
-  };
-
-  private abstract static class Event {
-    // NOTE: EventList only supports direct subclasses, not an arbitrary
-    // hierarchy.
-
-    final Account.Id who;
-    final Timestamp when;
-    final String tag;
-    final boolean predatesChange;
-    PatchSet.Id psId;
-
-    protected Event(PatchSet.Id psId, Account.Id who, Timestamp when,
-        Timestamp changeCreatedOn, String tag) {
-      this.psId = psId;
-      this.who = who;
-      this.tag = tag;
-      // Truncate timestamps at the change's createdOn timestamp.
-      predatesChange = when.before(changeCreatedOn);
-      this.when = predatesChange ? changeCreatedOn : when;
-    }
-
-    protected void checkUpdate(AbstractChangeUpdate update) {
-      checkState(Objects.equals(update.getPatchSetId(), psId),
-          "cannot apply event for %s to update for %s",
-          update.getPatchSetId(), psId);
-      checkState(when.getTime() - update.getWhen().getTime() <= MAX_WINDOW_MS,
-          "event at %s outside update window starting at %s",
-          when, update.getWhen());
-      checkState(Objects.equals(update.getNullableAccountId(), who),
-          "cannot apply event by %s to update by %s",
-          who, update.getNullableAccountId());
-    }
-
-    /**
-     * @return whether this event type must be unique per {@link ChangeUpdate},
-     *     i.e. there may be at most one of this type.
-     */
-    abstract boolean uniquePerUpdate();
-
-    abstract void apply(ChangeUpdate update) throws OrmException, IOException;
-
-    @Override
-    public String toString() {
-      return MoreObjects.toStringHelper(this)
-          .add("psId", psId)
-          .add("who", who)
-          .add("when", when)
-          .toString();
-    }
-  }
-
-  private class EventList<E extends Event> extends ArrayList<E> {
-    private static final long serialVersionUID = 1L;
-
-    private E getLast() {
-      return get(size() - 1);
-    }
-
-    private long getLastTime() {
-      return getLast().when.getTime();
-    }
-
-    private long getFirstTime() {
-      return get(0).when.getTime();
-    }
-
-    boolean canAdd(E e) {
-      if (isEmpty()) {
-        return true;
-      }
-      if (e instanceof FinalUpdatesEvent) {
-        return false; // FinalUpdatesEvent always gets its own update.
-      }
-
-      Event last = getLast();
-      if (!Objects.equals(e.who, last.who)
-          || !e.psId.equals(last.psId)
-          || !Objects.equals(e.tag, last.tag)) {
-        return false; // Different patch set, author, or tag.
-      }
-
-      long t = e.when.getTime();
-      long tFirst = getFirstTime();
-      long tLast = getLastTime();
-      checkArgument(t >= tLast,
-          "event %s is before previous event in list %s", e, last);
-      if (t - tLast > MAX_DELTA_MS || t - tFirst > MAX_WINDOW_MS) {
-        return false; // Too much time elapsed.
-      }
-
-      if (!e.uniquePerUpdate()) {
-        return true;
-      }
-      for (Event o : this) {
-        if (e.getClass() == o.getClass()) {
-          return false; // Only one event of this type allowed per update.
-        }
-      }
-
-      // TODO(dborowitz): Additional heuristics, like keeping events separate if
-      // they affect overlapping fields within a single entity.
-
-      return true;
-    }
-
-    Timestamp getWhen() {
-      return get(0).when;
-    }
-
-    PatchSet.Id getPatchSetId() {
-      PatchSet.Id id = checkNotNull(get(0).psId);
-      for (int i = 1; i < size(); i++) {
-        checkState(get(i).psId.equals(id),
-            "mismatched patch sets in EventList: %s != %s", id, get(i).psId);
-      }
-      return id;
-    }
-
-    Account.Id getAccountId() {
-      Account.Id id = get(0).who;
-      for (int i = 1; i < size(); i++) {
-        checkState(Objects.equals(id, get(i).who),
-            "mismatched users in EventList: %s != %s", id, get(i).who);
-      }
-      return id;
-    }
-
-    PersonIdent newAuthorIdent() {
-      Account.Id id = getAccountId();
-      if (id == null) {
-        return new PersonIdent(serverIdent, getWhen());
-      }
-      return changeNoteUtil.newIdent(
-          accountCache.get(id).getAccount(), getWhen(), serverIdent,
-          anonymousCowardName);
-    }
-
-    String getTag() {
-      return getLast().tag;
-    }
-  }
-
-  private static void createChange(ChangeUpdate update, Change change) {
-    update.setSubjectForCommit("Create change");
-    update.setChangeId(change.getKey().get());
-    update.setBranch(change.getDest().get());
-    update.setSubject(change.getOriginalSubject());
-  }
-
-  private static class CreateChangeEvent extends Event {
-    private final Change change;
-
-    private static PatchSet.Id psId(Change change, Integer minPsNum) {
-      int n;
-      if (minPsNum == null) {
-        // There were no patch sets for the change at all, so something is very
-        // wrong. Bail and use 1 as the patch set.
-        n = 1;
-      } else {
-        n = minPsNum;
-      }
-      return new PatchSet.Id(change.getId(), n);
-    }
-
-    CreateChangeEvent(Change change, Integer minPsNum) {
-      super(psId(change, minPsNum), change.getOwner(), change.getCreatedOn(),
-          change.getCreatedOn(), null);
-      this.change = change;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return true;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws IOException, OrmException {
-      checkUpdate(update);
-      createChange(update, change);
-    }
-  }
-
-  private static class ApprovalEvent extends Event {
-    private PatchSetApproval psa;
-
-    ApprovalEvent(PatchSetApproval psa, Timestamp changeCreatedOn) {
-      super(psa.getPatchSetId(), psa.getAccountId(), psa.getGranted(),
-          changeCreatedOn, psa.getTag());
-      this.psa = psa;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return false;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) {
-      checkUpdate(update);
-      update.putApproval(psa.getLabel(), psa.getValue());
-    }
-  }
-
-  private static class ReviewerEvent extends Event {
-    private Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer;
-
-    ReviewerEvent(
-        Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer,
-        Timestamp changeCreatedOn) {
-      super(
-          // Reviewers aren't generally associated with a particular patch set
-          // (although as an implementation detail they were in ReviewDb). Just
-          // use the latest patch set at the time of the event.
-          null,
-          reviewer.getColumnKey(), reviewer.getValue(), changeCreatedOn, null);
-      this.reviewer = reviewer;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return false;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws IOException, OrmException {
-      checkUpdate(update);
-      update.putReviewer(reviewer.getColumnKey(), reviewer.getRowKey());
-    }
-  }
-
-  private static class PatchSetEvent extends Event {
-    private final Change change;
-    private final PatchSet ps;
-    private final RevWalk rw;
-    private boolean createChange;
-
-    PatchSetEvent(Change change, PatchSet ps, RevWalk rw) {
-      super(ps.getId(), ps.getUploader(), ps.getCreatedOn(),
-          change.getCreatedOn(), null);
-      this.change = change;
-      this.ps = ps;
-      this.rw = rw;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return true;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws IOException, OrmException {
-      checkUpdate(update);
-      if (createChange) {
-        createChange(update, change);
-      } else {
-        update.setSubject(change.getSubject());
-        update.setSubjectForCommit("Create patch set " + ps.getPatchSetId());
-      }
-      setRevision(update, ps);
-      List<String> groups = ps.getGroups();
-      if (!groups.isEmpty()) {
-        update.setGroups(ps.getGroups());
-      }
-      if (ps.isDraft()) {
-        update.setPatchSetState(PatchSetState.DRAFT);
-      }
-    }
-
-    private void setRevision(ChangeUpdate update, PatchSet ps)
-        throws IOException {
-      String rev = ps.getRevision().get();
-      String cert = ps.getPushCertificate();
-      ObjectId id;
-      try {
-        id = ObjectId.fromString(rev);
-      } catch (InvalidObjectIdException e) {
-        update.setRevisionForMissingCommit(rev, cert);
-        return;
-      }
-      try {
-        update.setCommit(rw, id, cert);
-      } catch (MissingObjectException e) {
-        update.setRevisionForMissingCommit(rev, cert);
-        return;
-      }
-    }
-  }
-
-  private static class PatchLineCommentEvent extends Event {
-    public final PatchLineComment c;
-    private final Change change;
-    private final PatchSet ps;
-    private final PatchListCache cache;
-
-    PatchLineCommentEvent(PatchLineComment c, Change change, PatchSet ps,
-        PatchListCache cache) {
-      super(PatchLineCommentsUtil.getCommentPsId(c), c.getAuthor(),
-          c.getWrittenOn(), change.getCreatedOn(), c.getTag());
-      this.c = c;
-      this.change = change;
-      this.ps = ps;
-      this.cache = cache;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return false;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws OrmException {
-      checkUpdate(update);
-      if (c.getRevId() == null) {
-        setCommentRevId(c, cache, change, ps);
-      }
-      update.putComment(c);
-    }
-
-    void applyDraft(ChangeDraftUpdate draftUpdate) throws OrmException {
-      if (c.getRevId() == null) {
-        setCommentRevId(c, cache, change, ps);
-      }
-      draftUpdate.putComment(c);
-    }
-  }
-
-  private static class HashtagsEvent extends Event {
-    private final Set<String> hashtags;
-
-    HashtagsEvent(PatchSet.Id psId, Account.Id who, Timestamp when,
-        Set<String> hashtags, Timestamp changeCreatdOn) {
-      super(psId, who, when, changeCreatdOn,
-          // Somewhat confusingly, hashtags do not use the setTag method on
-          // AbstractChangeUpdate, so pass null as the tag.
-          null);
-      this.hashtags = hashtags;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      // Since these are produced from existing commits in the old NoteDb graph,
-      // we know that there must be one per commit in the rebuilt graph.
-      return true;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws OrmException {
-      update.setHashtags(hashtags);
-    }
-  }
-
-  private static class ChangeMessageEvent extends Event {
-    private static final Pattern TOPIC_SET_REGEXP =
-        Pattern.compile("^Topic set to (.+)$");
-    private static final Pattern TOPIC_CHANGED_REGEXP =
-        Pattern.compile("^Topic changed from (.+) to (.+)$");
-    private static final Pattern TOPIC_REMOVED_REGEXP =
-        Pattern.compile("^Topic (.+) removed$");
-
-    private static final Pattern STATUS_ABANDONED_REGEXP =
-        Pattern.compile("^Abandoned(\n.*)*$");
-    private static final Pattern STATUS_RESTORED_REGEXP =
-        Pattern.compile("^Restored(\n.*)*$");
-
-    private final ChangeMessage message;
-    private final Change noteDbChange;
-
-    ChangeMessageEvent(ChangeMessage message, Change noteDbChange,
-        Timestamp changeCreatedOn) {
-      super(message.getPatchSetId(), message.getAuthor(),
-          message.getWrittenOn(), changeCreatedOn, message.getTag());
-      this.message = message;
-      this.noteDbChange = noteDbChange;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return true;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws OrmException {
-      checkUpdate(update);
-      update.setChangeMessage(message.getMessage());
-      setTopic(update);
-      setStatus(update);
-    }
-
-    private void setTopic(ChangeUpdate update) {
-      String msg = message.getMessage();
-      if (msg == null) {
-        return;
-      }
-      Matcher m = TOPIC_SET_REGEXP.matcher(msg);
-      if (m.matches()) {
-        String topic = m.group(1);
-        update.setTopic(topic);
-        noteDbChange.setTopic(topic);
-        return;
-      }
-
-      m = TOPIC_CHANGED_REGEXP.matcher(msg);
-      if (m.matches()) {
-        String topic = m.group(2);
-        update.setTopic(topic);
-        noteDbChange.setTopic(topic);
-        return;
-      }
-
-      if (TOPIC_REMOVED_REGEXP.matcher(msg).matches()) {
-        update.setTopic(null);
-        noteDbChange.setTopic(null);
-      }
-    }
-
-    private void setStatus(ChangeUpdate update) {
-      String msg = message.getMessage();
-      if (msg == null) {
-        return;
-      }
-      if (STATUS_ABANDONED_REGEXP.matcher(msg).matches()) {
-        update.setStatus(Change.Status.ABANDONED);
-        noteDbChange.setStatus(Change.Status.ABANDONED);
-        return;
-      }
-
-      if (STATUS_RESTORED_REGEXP.matcher(msg).matches()) {
-        update.setStatus(Change.Status.NEW);
-        noteDbChange.setStatus(Change.Status.NEW);
-      }
-    }
-  }
-
-  private static class FinalUpdatesEvent extends Event {
-    private final Change change;
-    private final Change noteDbChange;
-
-    FinalUpdatesEvent(Change change, Change noteDbChange) {
-      super(change.currentPatchSetId(), change.getOwner(),
-          change.getLastUpdatedOn(), change.getCreatedOn(), null);
-      this.change = change;
-      this.noteDbChange = noteDbChange;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return true;
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    void apply(ChangeUpdate update) throws OrmException {
-      if (!Objects.equals(change.getTopic(), noteDbChange.getTopic())) {
-        update.setTopic(change.getTopic());
-      }
-      if (!Objects.equals(change.getStatus(), noteDbChange.getStatus())) {
-        // TODO(dborowitz): Stamp approximate approvals at this time.
-        update.fixStatus(change.getStatus());
-      }
-      if (change.getSubmissionId() != null) {
-        update.setSubmissionId(change.getSubmissionId());
-      }
-      if (!update.isEmpty()) {
-        update.setSubjectForCommit("Final NoteDb migration updates");
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
new file mode 100644
index 0000000..2bd61a7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.primitives.Bytes;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.util.MutableInteger;
+import org.eclipse.jgit.util.RawParseUtils;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.List;
+
+class ChangeRevisionNote extends RevisionNote<Comment> {
+  private static final byte[] CERT_HEADER =
+      "certificate version ".getBytes(UTF_8);
+  // See org.eclipse.jgit.transport.PushCertificateParser.END_SIGNATURE
+  private static final byte[] END_SIGNATURE =
+      "-----END PGP SIGNATURE-----\n".getBytes(UTF_8);
+
+  private final ChangeNoteUtil noteUtil;
+  private final Change.Id changeId;
+  private final PatchLineComment.Status status;
+  private String pushCert;
+
+  ChangeRevisionNote(ChangeNoteUtil noteUtil, Change.Id changeId,
+      ObjectReader reader, ObjectId noteId, PatchLineComment.Status status) {
+    super(reader, noteId);
+    this.noteUtil = noteUtil;
+    this.changeId = changeId;
+    this.status = status;
+  }
+
+  public String getPushCert() {
+    checkParsed();
+    return pushCert;
+  }
+
+  @Override
+  protected List<Comment> parse(byte[] raw, int offset)
+      throws IOException, ConfigInvalidException {
+    MutableInteger p = new MutableInteger();
+    p.value = offset;
+
+    if (isJson(raw, p.value)) {
+      RevisionNoteData data = parseJson(noteUtil, raw, p.value);
+      if (status == PatchLineComment.Status.PUBLISHED) {
+        pushCert = data.pushCert;
+      } else {
+        pushCert = null;
+      }
+      return data.comments;
+    }
+
+    if (status == PatchLineComment.Status.PUBLISHED) {
+      pushCert = parsePushCert(changeId, raw, p);
+      trimLeadingEmptyLines(raw, p);
+    } else {
+      pushCert = null;
+    }
+    return noteUtil.parseNote(raw, p, changeId);
+  }
+
+  private static boolean isJson(byte[] raw, int offset) {
+    return raw[offset] == '{' || raw[offset] == '[';
+  }
+
+  private RevisionNoteData parseJson(ChangeNoteUtil noteUtil, byte[] raw,
+      int offset) throws IOException {
+    try (InputStream is = new ByteArrayInputStream(
+        raw, offset, raw.length - offset);
+        Reader r = new InputStreamReader(is, UTF_8)) {
+      return noteUtil.getGson().fromJson(r, RevisionNoteData.class);
+    }
+  }
+
+  private static String parsePushCert(Change.Id changeId, byte[] bytes,
+      MutableInteger p) throws ConfigInvalidException {
+    if (RawParseUtils.match(bytes, p.value, CERT_HEADER) < 0) {
+      return null;
+    }
+    int end = Bytes.indexOf(bytes, END_SIGNATURE);
+    if (end < 0) {
+      throw ChangeNotes.parseException(
+          changeId, "invalid push certificate in note");
+    }
+    int start = p.value;
+    p.value = end + END_SIGNATURE.length;
+    return new String(bytes, start, p.value, UTF_8);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 77b8dc0..1a417e1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -19,6 +19,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
@@ -26,17 +27,18 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
+import static java.util.Comparator.comparing;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Table;
@@ -45,10 +47,11 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
@@ -56,6 +59,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.client.IntKey;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -78,6 +82,8 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 
 /**
@@ -96,8 +102,13 @@
   public interface Factory {
     ChangeUpdate create(ChangeControl ctl);
     ChangeUpdate create(ChangeControl ctl, Date when);
-    ChangeUpdate create(Change change, @Nullable Account.Id accountId,
-        PersonIdent authorIdent, Date when,
+
+    ChangeUpdate create(
+        Change change,
+        @Assisted("effective") @Nullable Account.Id accountId,
+        @Assisted("real") @Nullable Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when,
         Comparator<String> labelNameComparator);
 
     @VisibleForTesting
@@ -107,11 +118,12 @@
 
   private final AccountCache accountCache;
   private final ChangeDraftUpdate.Factory draftUpdateFactory;
+  private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
   private final NoteDbUpdateManager.Factory updateManagerFactory;
 
   private final Table<String, Account.Id, Optional<Short>> approvals;
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
-  private final List<PatchLineComment> comments = new ArrayList<>();
+  private final List<Comment> comments = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -122,6 +134,7 @@
   private String submissionId;
   private String topic;
   private String commit;
+  private Optional<Account.Id> assignee;
   private Set<String> hashtags;
   private String changeMessage;
   private String tag;
@@ -131,6 +144,7 @@
   private boolean isAllowWriteToNewtRef;
 
   private ChangeDraftUpdate draftUpdate;
+  private RobotCommentUpdate robotCommentUpdate;
 
   @AssistedInject
   private ChangeUpdate(
@@ -140,11 +154,12 @@
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       ProjectCache projectCache,
       @Assisted ChangeControl ctl,
       ChangeNoteUtil noteUtil) {
     this(serverIdent, anonymousCowardName, migration, accountCache,
-        updateManagerFactory, draftUpdateFactory,
+        updateManagerFactory, draftUpdateFactory, robotCommentUpdateFactory,
         projectCache, ctl, serverIdent.getWhen(), noteUtil);
   }
 
@@ -156,13 +171,14 @@
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       ProjectCache projectCache,
       @Assisted ChangeControl ctl,
       @Assisted Date when,
       ChangeNoteUtil noteUtil) {
     this(serverIdent, anonymousCowardName, migration, accountCache,
-        updateManagerFactory, draftUpdateFactory, ctl,
-        when,
+        updateManagerFactory, draftUpdateFactory, robotCommentUpdateFactory,
+        ctl, when,
         projectCache.get(getProjectName(ctl)).getLabelTypes().nameComparator(),
         noteUtil);
   }
@@ -173,7 +189,7 @@
 
   private static Table<String, Account.Id, Optional<Short>> approvals(
       Comparator<String> nameComparator) {
-    return TreeBasedTable.create(nameComparator, ReviewDbUtil.intKeyOrdering());
+    return TreeBasedTable.create(nameComparator, comparing(IntKey::get));
   }
 
   @AssistedInject
@@ -184,6 +200,7 @@
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       @Assisted ChangeControl ctl,
       @Assisted Date when,
       @Assisted Comparator<String> labelNameComparator,
@@ -192,6 +209,7 @@
         anonymousCowardName, noteUtil, when);
     this.accountCache = accountCache;
     this.draftUpdateFactory = draftUpdateFactory;
+    this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
     this.approvals = approvals(labelNameComparator);
   }
@@ -204,16 +222,19 @@
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
-      @Assisted @Nullable Account.Id accountId,
+      @Assisted("effective") @Nullable Account.Id accountId,
+      @Assisted("real") @Nullable Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when,
       @Assisted Comparator<String> labelNameComparator) {
     super(migration, noteUtil, serverIdent, anonymousCowardName, null, change,
-        accountId, authorIdent, when);
+        accountId, realAccountId, authorIdent, when);
     this.accountCache = accountCache;
     this.draftUpdateFactory = draftUpdateFactory;
+    this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
     this.approvals = approvals(labelNameComparator);
   }
@@ -263,7 +284,7 @@
   }
 
   public void removeApprovalFor(Account.Id reviewer, String label) {
-    approvals.put(label, reviewer, Optional.<Short> absent());
+    approvals.put(label, reviewer, Optional.empty());
   }
 
   public void merge(RequestId submissionId,
@@ -284,7 +305,7 @@
     this.commitSubject = commitSubject;
   }
 
-  void setSubject(String subject) {
+  public void setSubject(String subject) {
     this.subject = subject;
   }
 
@@ -301,10 +322,10 @@
     this.tag = tag;
   }
 
-  public void putComment(PatchLineComment c) {
+  public void putComment(PatchLineComment.Status status, Comment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
-    if (c.getStatus() == PatchLineComment.Status.DRAFT) {
+    if (status == PatchLineComment.Status.DRAFT) {
       draftUpdate.putComment(c);
     } else {
       comments.add(c);
@@ -316,14 +337,15 @@
     }
   }
 
-  public void deleteComment(PatchLineComment c) {
+  public void putRobotComment(RobotComment c) {
     verifyComment(c);
-    if (c.getStatus() == PatchLineComment.Status.DRAFT) {
-      createDraftUpdateIfNull().deleteComment(c);
-    } else {
-      throw new IllegalArgumentException(
-          "Cannot delete published comment " + c);
-    }
+    createRobotCommentUpdateIfNull();
+    robotCommentUpdate.putComment(c);
+  }
+
+  public void deleteComment(Comment c) {
+    verifyComment(c);
+    createDraftUpdateIfNull().deleteComment(c);
   }
 
   @VisibleForTesting
@@ -331,22 +353,29 @@
     if (draftUpdate == null) {
       ChangeNotes notes = getNotes();
       if (notes != null) {
-        draftUpdate =
-            draftUpdateFactory.create(notes, accountId, authorIdent, when);
+        draftUpdate = draftUpdateFactory.create(
+            notes, accountId, realAccountId, authorIdent, when);
       } else {
         draftUpdate = draftUpdateFactory.create(
-            getChange(), accountId, authorIdent, when);
+            getChange(), accountId, realAccountId, authorIdent, when);
       }
     }
     return draftUpdate;
   }
 
-  private void verifyComment(PatchLineComment c) {
-    checkArgument(c.getRevId() != null, "RevId required for comment: %s", c);
-    checkArgument(c.getAuthor().equals(getAccountId()),
-        "The author for the following comment does not match the author of"
-        + " this ChangeDraftUpdate (%s): %s", getAccountId(), c);
-
+  @VisibleForTesting
+  RobotCommentUpdate createRobotCommentUpdateIfNull() {
+    if (robotCommentUpdate == null) {
+      ChangeNotes notes = getNotes();
+      if (notes != null) {
+        robotCommentUpdate = robotCommentUpdateFactory.create(
+            notes, accountId, realAccountId, authorIdent, when);
+      } else {
+        robotCommentUpdate = robotCommentUpdateFactory.create(
+            getChange(), accountId, realAccountId, authorIdent, when);
+      }
+    }
+    return robotCommentUpdate;
   }
 
   public void setTopic(String topic) {
@@ -379,6 +408,15 @@
     this.hashtags = hashtags;
   }
 
+  public void setAssignee(Account.Id assignee) {
+    checkArgument(assignee != null, "use removeAssignee");
+    this.assignee = Optional.of(assignee);
+  }
+
+  public void removeAssignee() {
+    this.assignee = Optional.empty();
+  }
+
   public Map<Account.Id, ReviewerStateInternal> getReviewers() {
     return reviewers;
   }
@@ -407,12 +445,12 @@
     if (comments.isEmpty() && pushCert == null) {
       return null;
     }
-    RevisionNoteMap rnm = getRevisionNoteMap(rw, curr);
+    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
 
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
-    for (PatchLineComment c : comments) {
-      c.setTag(tag);
-      cache.get(c.getRevId()).putComment(c);
+    for (Comment c : comments) {
+      c.tag = tag;
+      cache.get(new RevId(c.revId)).putComment(c);
     }
     if (pushCert != null) {
       checkState(commit != null);
@@ -423,15 +461,15 @@
 
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
       ObjectId data = inserter.insert(
-          OBJ_BLOB, e.getValue().build(noteUtil));
+          OBJ_BLOB, e.getValue().build(noteUtil, noteUtil.getWriteJson()));
       rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data);
     }
 
     return rnm.noteMap.writeTree(inserter);
   }
 
-  private RevisionNoteMap getRevisionNoteMap(RevWalk rw, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
+  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw,
+      ObjectId curr) throws ConfigInvalidException, OrmException, IOException {
     if (curr.equals(ObjectId.zeroId())) {
       return RevisionNoteMap.emptyMap();
     }
@@ -452,16 +490,20 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil, getId(), rw.getObjectReader(), noteMap, false);
+        noteUtil,
+        getId(),
+        rw.getObjectReader(),
+        noteMap,
+        PatchLineComment.Status.PUBLISHED);
   }
 
-  private void checkComments(Map<RevId, RevisionNote> existingNotes,
+  private void checkComments(Map<RevId, ChangeRevisionNote> existingNotes,
       Map<RevId, RevisionNoteBuilder> toUpdate) throws OrmException {
     // Prohibit various kinds of illegal operations on comments.
-    Set<PatchLineComment.Key> existing = new HashSet<>();
-    for (RevisionNote rn : existingNotes.values()) {
-      for (PatchLineComment c : rn.comments) {
-        existing.add(c.getKey());
+    Set<Comment.Key> existing = new HashSet<>();
+    for (ChangeRevisionNote rn : existingNotes.values()) {
+      for (Comment c : rn.getComments()) {
+        existing.add(c.key);
         if (draftUpdate != null) {
           // Take advantage of an existing update on All-Users to prune any
           // published comments from drafts. NoteDbUpdateManager takes care of
@@ -478,14 +520,14 @@
           // separate commit. But note that we don't care much about the commit
           // graph of the draft ref, particularly because the ref is completely
           // deleted when all drafts are gone.
-          draftUpdate.deleteComment(c.getRevId(), c.getKey());
+          draftUpdate.deleteComment(c.revId, c.key);
         }
       }
     }
 
     for (RevisionNoteBuilder b : toUpdate.values()) {
-      for (PatchLineComment c : b.put.values()) {
-        if (existing.contains(c.getKey())) {
+      for (Comment c : b.put.values()) {
+        if (existing.contains(c.key)) {
           throw new OrmException(
               "Cannot update existing published comment: " + c);
         }
@@ -543,6 +585,15 @@
       addFooter(msg, FOOTER_COMMIT, commit);
     }
 
+    if (assignee != null) {
+      if (assignee.isPresent()) {
+        addFooter(msg, FOOTER_ASSIGNEE);
+        addIdent(msg, assignee.get()).append('\n');
+      } else {
+        addFooter(msg, FOOTER_ASSIGNEE).append('\n');
+      }
+    }
+
     Joiner comma = Joiner.on(',');
     if (hashtags != null) {
       addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags));
@@ -595,10 +646,8 @@
             addFooter(msg, FOOTER_SUBMITTED_WITH)
                 .append(label.status).append(": ").append(label.label);
             if (label.appliedBy != null) {
-              PersonIdent ident =
-                  newIdent(accountCache.get(label.appliedBy).getAccount(), when);
-              msg.append(": ").append(ident.getName())
-                  .append(" <").append(ident.getEmailAddress()).append('>');
+              msg.append(": ");
+              addIdent(msg, label.appliedBy);
             }
             msg.append('\n');
           }
@@ -606,6 +655,11 @@
       }
     }
 
+    if (!Objects.equals(accountId, realAccountId)) {
+      addFooter(msg, FOOTER_REAL_USER);
+      addIdent(msg, realAccountId).append('\n');
+    }
+
     cb.setMessage(msg.toString());
     try {
       ObjectId treeId = storeRevisionNotes(rw, ins, curr);
@@ -643,6 +697,7 @@
         && status == null
         && submissionId == null
         && submitRecords == null
+        && assignee == null
         && hashtags == null
         && topic == null
         && commit == null
@@ -655,6 +710,10 @@
     return draftUpdate;
   }
 
+  RobotCommentUpdate getRobotCommentUpdate() {
+    return robotCommentUpdate;
+  }
+
   public void setAllowWriteToNewRef(boolean allow) {
     isAllowWriteToNewtRef = allow;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 08195e4..661112e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -31,6 +32,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.RepoRefCache;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.StagedResult;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
@@ -67,8 +69,8 @@
   private final Account.Id author;
   private final NoteDbUpdateManager.Result rebuildResult;
 
-  private ImmutableListMultimap<RevId, PatchLineComment> comments;
-  private RevisionNoteMap revisionNoteMap;
+  private ImmutableListMultimap<RevId, Comment> comments;
+  private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   @AssistedInject
   DraftCommentNotes(
@@ -101,7 +103,7 @@
     this.rebuildResult = rebuildResult;
   }
 
-  RevisionNoteMap getRevisionNoteMap() {
+  RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap() {
     return revisionNoteMap;
   }
 
@@ -109,13 +111,13 @@
     return author;
   }
 
-  public ImmutableListMultimap<RevId, PatchLineComment> getComments() {
+  public ImmutableListMultimap<RevId, Comment> getComments() {
     return comments;
   }
 
-  public boolean containsComment(PatchLineComment c) {
-    for (PatchLineComment existing : comments.values()) {
-      if (c.getKey().equals(existing.getKey())) {
+  public boolean containsComment(Comment c) {
+    for (Comment existing : comments.values()) {
+      if (c.key.equals(existing.key)) {
         return true;
       }
     }
@@ -140,11 +142,11 @@
     ObjectReader reader = handle.walk().getObjectReader();
     revisionNoteMap = RevisionNoteMap.parse(
         args.noteUtil, getChangeId(), reader, NoteMap.read(reader, tipCommit),
-        true);
-    Multimap<RevId, PatchLineComment> cs = ArrayListMultimap.create();
-    for (RevisionNote rn : revisionNoteMap.revisionNotes.values()) {
-      for (PatchLineComment c : rn.comments) {
-        cs.put(c.getRevId(), c);
+        PatchLineComment.Status.DRAFT);
+    Multimap<RevId, Comment> cs = ArrayListMultimap.create();
+    for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
+      for (Comment c : rn.getComments()) {
+        cs.put(new RevId(c.revId), c);
       }
     }
     comments = ImmutableListMultimap.copyOf(cs);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
new file mode 100644
index 0000000..e401a52
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.ChangeBundle.Source;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import java.util.List;
+
+@Singleton
+public class GwtormChangeBundleReader implements ChangeBundleReader {
+  @Inject
+  GwtormChangeBundleReader() {
+  }
+
+  @Override
+  public ChangeBundle fromReviewDb(ReviewDb db, Change.Id id)
+      throws OrmException {
+    db.changes().beginTransaction(id);
+    try {
+      List<PatchSetApproval> approvals =
+          db.patchSetApprovals().byChange(id).toList();
+      return new ChangeBundle(
+          db.changes().get(id),
+          db.changeMessages().byChange(id),
+          db.patchSets().byChange(id),
+          approvals,
+          db.patchComments().byChange(id),
+          ReviewerSet.fromApprovals(approvals),
+          Source.REVIEW_DB);
+    } finally {
+      db.rollback();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
index 4a7a781..2fefd72 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
@@ -16,30 +16,30 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.NOTE_DB;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
+import static org.eclipse.jgit.lib.ObjectId.zeroId;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicates;
 import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.git.RefCache;
-
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.io.IOException;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * The state of all relevant NoteDb refs across all repos corresponding to a
@@ -48,13 +48,35 @@
  * Stored serialized in the {@code Change#noteDbState} field, and used to
  * determine whether the state in NoteDb is out of date.
  * <p>
- * Serialized in the form:
- * <pre>
- *   [meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
- * </pre>
+ * Serialized in one of the forms:
+ * <ul>
+ *    <li>[meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
+ *    <li>R[meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
+ *    <li>N
+ * </ul>
+ *
  * in numeric account ID order, with hex SHA-1s for human readability.
  */
 public class NoteDbChangeState {
+  public static final String NOTE_DB_PRIMARY_STATE = "N";
+
+  public enum PrimaryStorage {
+    REVIEW_DB('R', true),
+    NOTE_DB('N', false);
+
+    private final char code;
+    private final boolean writeToReviewDb;
+
+    private PrimaryStorage(char code, boolean writeToReviewDb) {
+      this.code = code;
+      this.writeToReviewDb = writeToReviewDb;
+    }
+
+    public boolean writeToReviewDb() {
+      return writeToReviewDb;
+    }
+  }
+
   @AutoValue
   public abstract static class Delta {
     static Delta create(Change.Id changeId, Optional<ObjectId> newChangeMetaId,
@@ -73,31 +95,89 @@
     abstract ImmutableMap<Account.Id, ObjectId> newDraftIds();
   }
 
+  @AutoValue
+  public abstract static class RefState {
+    @VisibleForTesting
+    public static RefState create(ObjectId changeMetaId,
+        Map<Account.Id, ObjectId> draftIds) {
+      return new AutoValue_NoteDbChangeState_RefState(
+          changeMetaId.copy(),
+          ImmutableMap.copyOf(
+              Maps.filterValues(draftIds, id -> !zeroId().equals(id))));
+    }
+
+    private static Optional<RefState> parse(Change.Id changeId,
+        List<String> parts) {
+      checkArgument(!parts.isEmpty(),
+          "missing state string for change %s", changeId);
+      ObjectId changeMetaId = ObjectId.fromString(parts.get(0));
+      Map<Account.Id, ObjectId> draftIds =
+          Maps.newHashMapWithExpectedSize(parts.size() - 1);
+      Splitter s = Splitter.on('=');
+      for (int i = 1; i < parts.size(); i++) {
+        String p = parts.get(i);
+        List<String> draftParts = s.splitToList(p);
+        checkArgument(draftParts.size() == 2,
+            "invalid draft state part for change %s: %s", changeId, p);
+        draftIds.put(Account.Id.parse(draftParts.get(0)),
+            ObjectId.fromString(draftParts.get(1)));
+      }
+      return Optional.of(create(changeMetaId, draftIds));
+    }
+
+    abstract ObjectId changeMetaId();
+    abstract ImmutableMap<Account.Id, ObjectId> draftIds();
+
+    @Override
+    public String toString() {
+      return appendTo(new StringBuilder()).toString();
+    }
+
+    StringBuilder appendTo(StringBuilder sb) {
+      sb.append(changeMetaId().name());
+      for (Account.Id id : ReviewDbUtil.intKeyOrdering()
+          .sortedCopy(draftIds().keySet())) {
+        sb.append(',')
+            .append(id.get())
+            .append('=')
+            .append(draftIds().get(id).name());
+      }
+      return sb;
+    }
+  }
+
   public static NoteDbChangeState parse(Change c) {
-    return parse(c.getId(), c.getNoteDbState());
+    return c != null ? parse(c.getId(), c.getNoteDbState()) : null;
   }
 
   @VisibleForTesting
-  static NoteDbChangeState parse(Change.Id id, String str) {
-    if (str == null) {
+  public static NoteDbChangeState parse(Change.Id id, String str) {
+    if (Strings.isNullOrEmpty(str)) {
+      // Return null rather than Optional as this is what goes in the field in
+      // ReviewDb.
       return null;
     }
     List<String> parts = Splitter.on(',').splitToList(str);
-    checkArgument(!parts.isEmpty(),
-        "invalid state string for change %s: %s", id, str);
-    ObjectId changeMetaId = ObjectId.fromString(parts.get(0));
-    Map<Account.Id, ObjectId> draftIds =
-        Maps.newHashMapWithExpectedSize(parts.size() - 1);
-    Splitter s = Splitter.on('=');
-    for (int i = 1; i < parts.size(); i++) {
-      String p = parts.get(i);
-      List<String> draftParts = s.splitToList(p);
-      checkArgument(draftParts.size() == 2,
-          "invalid draft state part for change %s: %s", id, p);
-      draftIds.put(Account.Id.parse(draftParts.get(0)),
-          ObjectId.fromString(draftParts.get(1)));
+
+    // Only valid NOTE_DB state is "N".
+    String first = parts.get(0);
+    if (parts.size() == 1 && first.charAt(0) == NOTE_DB.code) {
+      return new NoteDbChangeState(id, NOTE_DB, Optional.empty());
     }
-    return new NoteDbChangeState(id, changeMetaId, draftIds);
+
+    // Otherwise it must be REVIEW_DB, either "R,<RefState>" or just
+    // "<RefState>". Allow length > 0 for forward compatibility.
+    if (first.length() > 0) {
+      Optional<RefState> refState;
+      if (first.charAt(0) == REVIEW_DB.code) {
+        refState = RefState.parse(id, parts.subList(1, parts.size()));
+      } else {
+        refState = RefState.parse(id, parts);
+      }
+      return new NoteDbChangeState(id, REVIEW_DB, refState);
+    }
+    throw new IllegalArgumentException(
+        "invalid state string for change " + id + ": " + str);
   }
 
   public static NoteDbChangeState applyDelta(Change change, Delta delta) {
@@ -112,6 +192,10 @@
       return null;
     }
     NoteDbChangeState oldState = parse(change.getId(), oldStr);
+    if (oldState != null && oldState.getPrimaryStorage() == NOTE_DB) {
+      // NOTE_DB state doesn't include RefState, so applying a delta is a no-op.
+      return oldState;
+    }
 
     ObjectId changeMetaId;
     if (delta.newChangeMetaId().isPresent()) {
@@ -121,12 +205,12 @@
         return null;
       }
     } else {
-      changeMetaId = oldState.changeMetaId;
+      changeMetaId = oldState.getChangeMetaId();
     }
 
     Map<Account.Id, ObjectId> draftIds = new HashMap<>();
     if (oldState != null) {
-      draftIds.putAll(oldState.draftIds);
+      draftIds.putAll(oldState.getDraftIds());
     }
     for (Map.Entry<Account.Id, ObjectId> e : delta.newDraftIds().entrySet()) {
       if (e.getValue().equals(ObjectId.zeroId())) {
@@ -137,7 +221,11 @@
     }
 
     NoteDbChangeState state = new NoteDbChangeState(
-        change.getId(), changeMetaId, draftIds);
+        change.getId(),
+        oldState != null
+            ? oldState.getPrimaryStorage()
+            : REVIEW_DB,
+        Optional.of(RefState.create(changeMetaId, draftIds)));
     change.setNoteDbState(state.toString());
     return state;
   }
@@ -160,38 +248,47 @@
     return state.areDraftsUpToDate(draftsRepoRefs, accountId);
   }
 
-  public static String toString(ObjectId changeMetaId,
-      Map<Account.Id, ObjectId> draftIds) {
-    List<Account.Id> accountIds = Lists.newArrayList(draftIds.keySet());
-    Collections.sort(accountIds, ReviewDbUtil.intKeyOrdering());
-    StringBuilder sb = new StringBuilder(changeMetaId.name());
-    for (Account.Id id : accountIds) {
-      sb.append(',')
-          .append(id.get())
-          .append('=')
-          .append(draftIds.get(id).name());
+  private final Change.Id changeId;
+  private final PrimaryStorage primaryStorage;
+  private final Optional<RefState> refState;
+
+  public NoteDbChangeState(
+      Change.Id changeId,
+      PrimaryStorage primaryStorage,
+      Optional<RefState> refState) {
+    this.changeId = checkNotNull(changeId);
+    this.primaryStorage = checkNotNull(primaryStorage);
+    this.refState = refState;
+
+    switch (primaryStorage) {
+      case REVIEW_DB:
+        checkArgument(
+            refState.isPresent(),
+            "expected RefState for change %s with primary storage %s",
+            changeId, primaryStorage);
+        break;
+      case NOTE_DB:
+        checkArgument(
+            !refState.isPresent(),
+            "expected no RefState for change %s with primary storage %s",
+            changeId, primaryStorage);
+        break;
+      default:
+        throw new IllegalStateException(
+            "invalid PrimaryStorage: " + primaryStorage);
     }
-    return sb.toString();
   }
 
-  private final Change.Id changeId;
-  private final ObjectId changeMetaId;
-  private final ImmutableMap<Account.Id, ObjectId> draftIds;
-
-  public NoteDbChangeState(Change.Id changeId, ObjectId changeMetaId,
-      Map<Account.Id, ObjectId> draftIds) {
-    this.changeId = checkNotNull(changeId);
-    this.changeMetaId = checkNotNull(changeMetaId);
-    this.draftIds = ImmutableMap.copyOf(Maps.filterValues(
-        draftIds, Predicates.not(Predicates.equalTo(ObjectId.zeroId()))));
+  public PrimaryStorage getPrimaryStorage() {
+    return primaryStorage;
   }
 
   public boolean isChangeUpToDate(RefCache changeRepoRefs) throws IOException {
     Optional<ObjectId> id = changeRepoRefs.get(changeMetaRef(changeId));
     if (!id.isPresent()) {
-      return changeMetaId.equals(ObjectId.zeroId());
+      return getChangeMetaId().equals(ObjectId.zeroId());
     }
-    return id.get().equals(changeMetaId);
+    return id.get().equals(getChangeMetaId());
   }
 
   public boolean areDraftsUpToDate(RefCache draftsRepoRefs, Account.Id accountId)
@@ -199,17 +296,17 @@
     Optional<ObjectId> id =
         draftsRepoRefs.get(refsDraftComments(changeId, accountId));
     if (!id.isPresent()) {
-      return !draftIds.containsKey(accountId);
+      return !getDraftIds().containsKey(accountId);
     }
-    return id.get().equals(draftIds.get(accountId));
+    return id.get().equals(getDraftIds().get(accountId));
   }
 
-  boolean isUpToDate(RefCache changeRepoRefs, RefCache draftsRepoRefs)
+  public boolean isUpToDate(RefCache changeRepoRefs, RefCache draftsRepoRefs)
       throws IOException {
     if (!isChangeUpToDate(changeRepoRefs)) {
       return false;
     }
-    for (Account.Id accountId : draftIds.keySet()) {
+    for (Account.Id accountId : getDraftIds().keySet()) {
       if (!areDraftsUpToDate(draftsRepoRefs, accountId)) {
         return false;
       }
@@ -224,16 +321,36 @@
 
   @VisibleForTesting
   public ObjectId getChangeMetaId() {
-    return changeMetaId;
+    return refState().changeMetaId();
   }
 
   @VisibleForTesting
   ImmutableMap<Account.Id, ObjectId> getDraftIds() {
-    return draftIds;
+    return refState().draftIds();
+  }
+
+  @VisibleForTesting
+  Optional<RefState> getRefState() {
+    return refState;
+  }
+
+  private RefState refState() {
+    checkState(refState.isPresent(),
+        "state for %s has no RefState: %s", changeId, this);
+    return refState.get();
   }
 
   @Override
   public String toString() {
-    return toString(changeMetaId, draftIds);
+    switch (primaryStorage) {
+      case REVIEW_DB:
+        // Don't include enum field, just IDs (though parse would accept it).
+        return refState().toString();
+      case NOTE_DB:
+        return NOTE_DB_PRIMARY_STATE;
+      default:
+        throw new IllegalArgumentException(
+          "Unsupported PrimaryStorage: " + primaryStorage);
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
index ff3b4b8..c4d7277 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
@@ -16,18 +16,16 @@
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
-import com.google.common.collect.ImmutableMultimap;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Id;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
 
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
 
 public class NoteDbModule extends FactoryModule {
   private final Config cfg;
@@ -51,6 +49,8 @@
     factory(ChangeUpdate.Factory.class);
     factory(ChangeDraftUpdate.Factory.class);
     factory(DraftCommentNotes.Factory.class);
+    factory(RobotCommentUpdate.Factory.class);
+    factory(RobotCommentNotes.Factory.class);
     factory(NoteDbUpdateManager.Factory.class);
     if (!useTestBindings) {
       install(ChangeNotesCache.module());
@@ -75,13 +75,6 @@
         }
 
         @Override
-        public boolean rebuildProject(ReviewDb db,
-            ImmutableMultimap<NameKey, Id> allChanges, NameKey project,
-            Repository allUsersRepo) {
-          return false;
-        }
-
-        @Override
         public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId) {
           return null;
         }
@@ -91,6 +84,12 @@
             NoteDbUpdateManager manager) {
           return null;
         }
+
+        @Override
+        public void buildUpdates(NoteDbUpdateManager manager,
+            ChangeBundle bundle) {
+          // Do nothing.
+        }
       });
       bind(new TypeLiteral<Cache<ChangeNotesCache.Key, ChangeNotesState>>() {})
           .annotatedWith(Names.named(ChangeNotesCache.CACHE_NAME))
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index cad531f..1f64bcc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -21,7 +21,6 @@
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Optional;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
@@ -58,6 +57,7 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 
 /**
@@ -70,7 +70,7 @@
  * of updates, use {@link #stage()}.
  */
 public class NoteDbUpdateManager implements AutoCloseable {
-  public static String CHANGES_READ_ONLY = "NoteDb changes are read-only";
+  public static final String CHANGES_READ_ONLY = "NoteDb changes are read-only";
 
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
@@ -78,8 +78,9 @@
 
   @AutoValue
   public abstract static class StagedResult {
-    private static StagedResult create(Change.Id id, NoteDbChangeState.Delta delta,
-        OpenRepo changeRepo, OpenRepo allUsersRepo) {
+    private static StagedResult create(Change.Id id,
+        NoteDbChangeState.Delta delta, OpenRepo changeRepo,
+        OpenRepo allUsersRepo) {
       ImmutableList<ReceiveCommand> changeCommands = ImmutableList.of();
       ImmutableList<InsertedObject> changeObjects = ImmutableList.of();
       if (changeRepo != null) {
@@ -119,10 +120,10 @@
     @Nullable abstract NoteDbUpdateManager.StagedResult staged();
   }
 
-  static class OpenRepo implements AutoCloseable {
-    final Repository repo;
-    final RevWalk rw;
-    final ChainedReceiveCommands cmds;
+  public static class OpenRepo implements AutoCloseable {
+    public final Repository repo;
+    public final RevWalk rw;
+    public final ChainedReceiveCommands cmds;
 
     private final InMemoryInserter tempIns;
     @Nullable private final ObjectInserter finalIns;
@@ -143,7 +144,7 @@
       this.close = close;
     }
 
-    Optional<ObjectId> getObjectId(String refName) throws IOException {
+    public Optional<ObjectId> getObjectId(String refName) throws IOException {
       return cmds.get(refName);
     }
 
@@ -179,6 +180,7 @@
   private final Project.NameKey projectName;
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
   private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
+  private final ListMultimap<String, RobotCommentUpdate> robotCommentUpdates;
   private final Set<Change.Id> toDelete;
 
   private OpenRepo changeRepo;
@@ -199,6 +201,7 @@
     this.projectName = projectName;
     changeUpdates = ArrayListMultimap.create();
     draftUpdates = ArrayListMultimap.create();
+    robotCommentUpdates = ArrayListMultimap.create();
     toDelete = new HashSet<>();
   }
 
@@ -233,17 +236,17 @@
     return this;
   }
 
-  NoteDbUpdateManager setCheckExpectedState(boolean checkExpectedState) {
+  public NoteDbUpdateManager setCheckExpectedState(boolean checkExpectedState) {
     this.checkExpectedState = checkExpectedState;
     return this;
   }
 
-  OpenRepo getChangeRepo() throws IOException {
+  public OpenRepo getChangeRepo() throws IOException {
     initChangeRepo();
     return changeRepo;
   }
 
-  OpenRepo getAllUsersRepo() throws IOException {
+  public OpenRepo getAllUsersRepo() throws IOException {
     initAllUsersRepo();
     return allUsersRepo;
   }
@@ -273,6 +276,7 @@
     }
     return changeUpdates.isEmpty()
         && draftUpdates.isEmpty()
+        && robotCommentUpdates.isEmpty()
         && toDelete.isEmpty();
   }
 
@@ -294,6 +298,10 @@
     if (du != null) {
       draftUpdates.put(du.getRefName(), du);
     }
+    RobotCommentUpdate rcu = update.getRobotCommentUpdate();
+    if (rcu != null) {
+      robotCommentUpdates.put(rcu.getRefName(), rcu);
+    }
   }
 
   public void add(ChangeDraftUpdate draftUpdate) {
@@ -354,7 +362,7 @@
         StagedResult r = StagedResult.create(
             e.getKey(),
             NoteDbChangeState.Delta.create(
-                e.getKey(), Optional.<ObjectId>absent(), e.getValue()),
+                e.getKey(), Optional.empty(), e.getValue()),
             changeRepo, allUsersRepo);
         checkState(r.changeCommands().isEmpty(),
             "should not have change commands when updating only drafts: %s", r);
@@ -453,6 +461,9 @@
     if (!draftUpdates.isEmpty()) {
       addUpdates(draftUpdates, allUsersRepo);
     }
+    if (!robotCommentUpdates.isEmpty()) {
+      addUpdates(robotCommentUpdates, changeRepo);
+    }
     for (Change.Id id : toDelete) {
       doDelete(id);
     }
@@ -539,7 +550,7 @@
     for (Map.Entry<String, Collection<U>> e : all.asMap().entrySet()) {
       String refName = e.getKey();
       Collection<U> updates = e.getValue();
-      ObjectId old = or.cmds.get(refName).or(ObjectId.zeroId());
+      ObjectId old = or.cmds.get(refName).orElse(ObjectId.zeroId());
       // Only actually write to the ref if one of the updates explicitly allows
       // us to do so, i.e. it is known to represent a new change. This avoids
       // writing partial change meta if the change hasn't been backfilled yet.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
index 071e12c..e4a6f7c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -83,7 +83,8 @@
         .withStopStrategy(StopStrategies.stopAfterDelay(30, TimeUnit.SECONDS));
   }
 
-  private static Retryer<RefUpdate.Result> RETRYER = retryerBuilder().build();
+  private static final Retryer<RefUpdate.Result> RETRYER =
+      retryerBuilder().build();
 
   private final GitRepositoryManager repoManager;
   private final Project.NameKey projectName;
@@ -197,7 +198,9 @@
       limit = counter + count;
       acquireCount++;
     } catch (ExecutionException | RetryException e) {
-      Throwables.propagateIfInstanceOf(e.getCause(), OrmException.class);
+      if (e.getCause() != null) {
+        Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
+      }
       throw new OrmException(e);
     } catch (IOException e) {
       throw new OrmException(e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
index 73ad68e..46e6dc5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
@@ -14,72 +14,66 @@
 
 package com.google.gerrit.server.notedb;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.common.base.Preconditions.checkState;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.primitives.Bytes;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.util.MutableInteger;
-import org.eclipse.jgit.util.RawParseUtils;
 
 import java.io.IOException;
+import java.util.List;
 
-class RevisionNote {
+abstract class RevisionNote<T extends Comment> {
   static final int MAX_NOTE_SZ = 25 << 20;
 
-  private static final byte[] CERT_HEADER =
-      "certificate version ".getBytes(UTF_8);
-  // See org.eclipse.jgit.transport.PushCertificateParser.END_SIGNATURE
-  private static final byte[] END_SIGNATURE =
-      "-----END PGP SIGNATURE-----\n".getBytes(UTF_8);
-
-  private static void trimLeadingEmptyLines(byte[] bytes, MutableInteger p) {
+  protected static void trimLeadingEmptyLines(byte[] bytes, MutableInteger p) {
     while (p.value < bytes.length && bytes[p.value] == '\n') {
       p.value++;
     }
   }
 
-  private static String parsePushCert(Change.Id changeId, byte[] bytes,
-      MutableInteger p) throws ConfigInvalidException {
-    if (RawParseUtils.match(bytes, p.value, CERT_HEADER) < 0) {
-      return null;
-    }
-    int end = Bytes.indexOf(bytes, END_SIGNATURE);
-    if (end < 0) {
-      throw ChangeNotes.parseException(
-          changeId, "invalid push certificate in note");
-    }
-    int start = p.value;
-    p.value = end + END_SIGNATURE.length;
-    return new String(bytes, start, p.value);
+  private final ObjectReader reader;
+  private final ObjectId noteId;
+
+  private byte[] raw;
+  private ImmutableList<T> comments;
+
+  RevisionNote(ObjectReader reader, ObjectId noteId) {
+    this.reader = reader;
+    this.noteId = noteId;
   }
 
-  final byte[] raw;
-  final ImmutableList<PatchLineComment> comments;
-  final String pushCert;
+  public byte[] getRaw() {
+    checkParsed();
+    return raw;
+  }
 
-  RevisionNote(ChangeNoteUtil noteUtil, Change.Id changeId,
-      ObjectReader reader, ObjectId noteId, boolean draftsOnly)
-      throws ConfigInvalidException, IOException {
+  public ImmutableList<T> getComments() {
+    checkParsed();
+    return comments;
+  }
+
+  public void parse() throws IOException, ConfigInvalidException {
     raw = reader.open(noteId, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
     MutableInteger p = new MutableInteger();
     trimLeadingEmptyLines(raw, p);
-    if (!draftsOnly) {
-      pushCert = parsePushCert(changeId, raw, p);
-      trimLeadingEmptyLines(raw, p);
-    } else {
-      pushCert = null;
+    if (p.value >= raw.length) {
+      comments = null;
+      return;
     }
-    PatchLineComment.Status status = draftsOnly
-        ? PatchLineComment.Status.DRAFT
-        : PatchLineComment.Status.PUBLISHED;
-    comments = ImmutableList.copyOf(
-        noteUtil.parseNote(raw, p, changeId, status));
+
+    comments = ImmutableList.copyOf(parse(raw, p.value));
+  }
+
+  protected abstract List<T> parse(byte[] raw, int offset)
+      throws IOException, ConfigInvalidException;
+
+  protected void checkParsed() {
+    checkState(raw != null, "revision note not parsed yet");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index c8364d3..8491f57 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -15,16 +15,19 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.RevId;
 
 import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -34,10 +37,12 @@
 
 class RevisionNoteBuilder {
   static class Cache {
-    private final RevisionNoteMap revisionNoteMap;
+    private final RevisionNoteMap<?
+        extends RevisionNote<? extends Comment>> revisionNoteMap;
     private final Map<RevId, RevisionNoteBuilder> builders;
 
-    Cache(RevisionNoteMap revisionNoteMap) {
+    Cache(RevisionNoteMap<?
+        extends RevisionNote<? extends Comment>> revisionNoteMap) {
       this.revisionNoteMap = revisionNoteMap;
       this.builders = new HashMap<>();
     }
@@ -58,18 +63,20 @@
   }
 
   final byte[] baseRaw;
-  final List<PatchLineComment> baseComments;
-  final Map<PatchLineComment.Key, PatchLineComment> put;
-  final Set<PatchLineComment.Key> delete;
+  final List<? extends Comment> baseComments;
+  final Map<Comment.Key, Comment> put;
+  final Set<Comment.Key> delete;
 
   private String pushCert;
 
-  RevisionNoteBuilder(RevisionNote base) {
+  RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
     if (base != null) {
-      baseRaw = base.raw;
-      baseComments = base.comments;
-      put = Maps.newHashMapWithExpectedSize(base.comments.size());
-      pushCert = base.pushCert;
+      baseRaw = base.getRaw();
+      baseComments = base.getComments();
+      put = Maps.newHashMapWithExpectedSize(baseComments.size());
+      if (base instanceof ChangeRevisionNote) {
+        pushCert = ((ChangeRevisionNote) base).getPushCert();
+      }
     } else {
       baseRaw = new byte[0];
       baseComments = Collections.emptyList();
@@ -79,13 +86,24 @@
     delete = new HashSet<>();
   }
 
-  void putComment(PatchLineComment comment) {
-    checkArgument(!delete.contains(comment.getKey()),
-        "cannot both delete and put %s", comment.getKey());
-    put.put(comment.getKey(), comment);
+  public byte[] build(ChangeNoteUtil noteUtil, boolean writeJson)
+      throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    if (writeJson) {
+      buildNoteJson(noteUtil, out);
+    } else {
+      buildNoteLegacy(noteUtil, out);
+    }
+    return out.toByteArray();
   }
 
-  void deleteComment(PatchLineComment.Key key) {
+  void putComment(Comment comment) {
+    checkArgument(!delete.contains(comment.key),
+        "cannot both delete and put %s", comment.key);
+    put.put(comment.key, comment);
+  }
+
+  void deleteComment(Comment.Key key) {
     checkArgument(!put.containsKey(key), "cannot both delete and put %s", key);
     delete.add(key);
   }
@@ -94,27 +112,46 @@
     this.pushCert = pushCert;
   }
 
-  byte[] build(ChangeNoteUtil noteUtil) {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
+  private Multimap<Integer, Comment> buildCommentMap() {
+    Multimap<Integer, Comment> all = ArrayListMultimap.create();
+
+    for (Comment c : baseComments) {
+      if (!delete.contains(c.key) && !put.containsKey(c.key)) {
+        all.put(c.key.patchSetId, c);
+      }
+    }
+    for (Comment c : put.values()) {
+      if (!delete.contains(c.key)) {
+        all.put(c.key.patchSetId, c);
+      }
+    }
+    return all;
+  }
+
+  private void buildNoteJson(ChangeNoteUtil noteUtil, OutputStream out)
+      throws IOException {
+    Multimap<Integer, Comment> comments = buildCommentMap();
+    if (comments.isEmpty() && pushCert == null) {
+      return;
+    }
+
+    RevisionNoteData data = new RevisionNoteData();
+    data.comments = COMMENT_ORDER.sortedCopy(comments.values());
+    data.pushCert = pushCert;
+
+    try (OutputStreamWriter osw = new OutputStreamWriter(out, UTF_8)) {
+      noteUtil.getGson().toJson(data, osw);
+    }
+  }
+
+  private void buildNoteLegacy(ChangeNoteUtil noteUtil, OutputStream out)
+      throws IOException {
     if (pushCert != null) {
       byte[] certBytes = pushCert.getBytes(UTF_8);
       out.write(certBytes, 0, trimTrailingNewlines(certBytes));
       out.write('\n');
     }
-
-    Multimap<PatchSet.Id, PatchLineComment> all = ArrayListMultimap.create();
-    for (PatchLineComment c : baseComments) {
-      if (!delete.contains(c.getKey()) && !put.containsKey(c.getKey())) {
-        all.put(c.getPatchSetId(), c);
-      }
-    }
-    for (PatchLineComment c : put.values()) {
-      if (!delete.contains(c.getKey())) {
-        all.put(c.getPatchSetId(), c);
-      }
-    }
-    noteUtil.buildNote(all, out);
-    return out.toByteArray();
+    noteUtil.buildNote(buildCommentMap(), out);
   }
 
   private static int trimTrailingNewlines(byte[] bytes) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteData.java
similarity index 61%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteData.java
index 9bcabc3..e0ee934 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteData.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Comment;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
+import java.util.List;
 
-  Address from(Account.Id fromId);
+/**
+ * Holds the raw data of a RevisionNote.
+ * <p>It is intended for (de)serialization to JSON only.
+ */
+class RevisionNoteData {
+  String pushCert;
+  List<Comment> comments;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index cd70528..8a9f711 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -16,6 +16,8 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.RevId;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -27,29 +29,45 @@
 import java.util.HashMap;
 import java.util.Map;
 
-class RevisionNoteMap {
+class RevisionNoteMap<T extends RevisionNote<? extends Comment>> {
   final NoteMap noteMap;
-  final ImmutableMap<RevId, RevisionNote> revisionNotes;
+  final ImmutableMap<RevId, T> revisionNotes;
 
-  static RevisionNoteMap parse(ChangeNoteUtil noteUtil,
+  static RevisionNoteMap<ChangeRevisionNote> parse(ChangeNoteUtil noteUtil,
       Change.Id changeId, ObjectReader reader, NoteMap noteMap,
-      boolean draftsOnly) throws ConfigInvalidException, IOException {
-    Map<RevId, RevisionNote> result = new HashMap<>();
+      PatchLineComment.Status status)
+          throws ConfigInvalidException, IOException {
+    Map<RevId, ChangeRevisionNote> result = new HashMap<>();
     for (Note note : noteMap) {
-      RevisionNote rn = new RevisionNote(
-          noteUtil, changeId, reader, note.getData(), draftsOnly);
+      ChangeRevisionNote rn = new ChangeRevisionNote(
+          noteUtil, changeId, reader, note.getData(), status);
+      rn.parse();
       result.put(new RevId(note.name()), rn);
     }
-    return new RevisionNoteMap(noteMap, ImmutableMap.copyOf(result));
+    return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
   }
 
-  static RevisionNoteMap emptyMap() {
-    return new RevisionNoteMap(NoteMap.newEmptyMap(),
-        ImmutableMap.<RevId, RevisionNote> of());
+  static RevisionNoteMap<RobotCommentsRevisionNote> parseRobotComments(
+      ChangeNoteUtil noteUtil, ObjectReader reader, NoteMap noteMap)
+          throws ConfigInvalidException, IOException {
+    Map<RevId, RobotCommentsRevisionNote> result = new HashMap<>();
+    for (Note note : noteMap) {
+      RobotCommentsRevisionNote rn = new RobotCommentsRevisionNote(
+          noteUtil, reader, note.getData());
+      rn.parse();
+      result.put(new RevId(note.name()), rn);
+    }
+    return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
+  }
+
+  static <T extends RevisionNote<? extends Comment>> RevisionNoteMap<T>
+      emptyMap() {
+    return new RevisionNoteMap<>(NoteMap.newEmptyMap(),
+        ImmutableMap.<RevId, T> of());
   }
 
   private RevisionNoteMap(NoteMap noteMap,
-      ImmutableMap<RevId, RevisionNote> revisionNotes) {
+      ImmutableMap<RevId, T> revisionNotes) {
     this.noteMap = noteMap;
     this.revisionNotes = revisionNotes;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
new file mode 100644
index 0000000..4a26d7e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+import java.io.IOException;
+
+public class RobotCommentNotes extends AbstractChangeNotes<RobotCommentNotes> {
+  public interface Factory {
+    RobotCommentNotes create(Change change);
+  }
+
+  private final Change change;
+
+  private ImmutableListMultimap<RevId, RobotComment> comments;
+  private RevisionNoteMap<RobotCommentsRevisionNote> revisionNoteMap;
+
+  @AssistedInject
+  RobotCommentNotes(
+      Args args,
+      @Assisted Change change) {
+    super(args, change.getId(), false);
+    this.change = change;
+  }
+
+  RevisionNoteMap<RobotCommentsRevisionNote> getRevisionNoteMap() {
+    return revisionNoteMap;
+  }
+
+  public ImmutableListMultimap<RevId, RobotComment> getComments() {
+    return comments;
+  }
+
+  public boolean containsComment(RobotComment c) {
+    for (RobotComment existing : comments.values()) {
+      if (c.key.equals(existing.key)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  @Override
+  protected String getRefName() {
+    return RefNames.robotCommentsRef(getChangeId());
+  }
+
+  @Override
+  protected void onLoad(LoadHandle handle)
+      throws IOException, ConfigInvalidException {
+    ObjectId rev = handle.id();
+    if (rev == null) {
+      loadDefaults();
+      return;
+    }
+
+    RevCommit tipCommit = handle.walk().parseCommit(rev);
+    ObjectReader reader = handle.walk().getObjectReader();
+    revisionNoteMap = RevisionNoteMap.parseRobotComments(args.noteUtil, reader,
+        NoteMap.read(reader, tipCommit));
+    Multimap<RevId, RobotComment> cs = ArrayListMultimap.create();
+    for (RobotCommentsRevisionNote rn :
+        revisionNoteMap.revisionNotes.values()) {
+      for (RobotComment c : rn.getComments()) {
+        cs.put(new RevId(c.revId), c);
+      }
+    }
+    comments = ImmutableListMultimap.copyOf(cs);
+  }
+
+  @Override
+  protected void loadDefaults() {
+    comments = ImmutableListMultimap.of();
+  }
+
+  @Override
+  public Project.NameKey getProjectName() {
+    return change.getProject();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
new file mode 100644
index 0000000..9744632
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -0,0 +1,226 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.reviewdb.client.RefNames.robotCommentsRef;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A single delta to apply atomically to a change.
+ * <p>
+ * This delta contains only robot comments on a single patch set of a change by
+ * a single author. This delta will become a single commit in the repository.
+ * <p>
+ * This class is not thread safe.
+ */
+public class RobotCommentUpdate extends AbstractChangeUpdate {
+  public interface Factory {
+    RobotCommentUpdate create(
+        ChangeNotes notes,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+
+    RobotCommentUpdate create(
+        Change change,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+  }
+
+  private List<RobotComment> put = new ArrayList<>();
+
+  @AssistedInject
+  private RobotCommentUpdate(
+      @GerritPersonIdent PersonIdent serverIdent,
+      @AnonymousCowardName String anonymousCowardName,
+      NotesMigration migration,
+      ChangeNoteUtil noteUtil,
+      @Assisted ChangeNotes notes,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(migration, noteUtil, serverIdent, anonymousCowardName, notes, null,
+        accountId, realAccountId, authorIdent, when);
+  }
+
+  @AssistedInject
+  private RobotCommentUpdate(
+      @GerritPersonIdent PersonIdent serverIdent,
+      @AnonymousCowardName String anonymousCowardName,
+      NotesMigration migration,
+      ChangeNoteUtil noteUtil,
+      @Assisted Change change,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(migration, noteUtil, serverIdent, anonymousCowardName, null, change,
+        accountId, realAccountId, authorIdent, when);
+  }
+
+  public void putComment(RobotComment c) {
+    verifyComment(c);
+    put.add(c);
+  }
+
+  private CommitBuilder storeCommentsInNotes(RevWalk rw, ObjectInserter ins,
+      ObjectId curr, CommitBuilder cb)
+      throws ConfigInvalidException, OrmException, IOException {
+    RevisionNoteMap<RobotCommentsRevisionNote> rnm =
+        getRevisionNoteMap(rw, curr);
+    Set<RevId> updatedRevs =
+        Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
+
+    for (RobotComment c : put) {
+      cache.get(new RevId(c.revId)).putComment(c);
+    }
+
+    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    boolean touchedAnyRevs = false;
+    boolean hasComments = false;
+    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
+      updatedRevs.add(e.getKey());
+      ObjectId id = ObjectId.fromString(e.getKey().get());
+      byte[] data = e.getValue().build(noteUtil, true);
+      if (!Arrays.equals(data, e.getValue().baseRaw)) {
+        touchedAnyRevs = true;
+      }
+      if (data.length == 0) {
+        rnm.noteMap.remove(id);
+      } else {
+        hasComments = true;
+        ObjectId dataBlob = ins.insert(OBJ_BLOB, data);
+        rnm.noteMap.set(id, dataBlob);
+      }
+    }
+
+    // If we didn't touch any notes, tell the caller this was a no-op update. We
+    // couldn't have done this in isEmpty() below because we hadn't read the old
+    // data yet.
+    if (!touchedAnyRevs) {
+      return NO_OP_UPDATE;
+    }
+
+    // If we touched every revision and there are no comments left, tell the
+    // caller to delete the entire ref.
+    boolean touchedAllRevs = updatedRevs.equals(rnm.revisionNotes.keySet());
+    if (touchedAllRevs && !hasComments) {
+      return null;
+    }
+
+    cb.setTreeId(rnm.noteMap.writeTree(ins));
+    return cb;
+  }
+
+  private RevisionNoteMap<RobotCommentsRevisionNote> getRevisionNoteMap(
+      RevWalk rw, ObjectId curr)
+          throws ConfigInvalidException, OrmException, IOException {
+    if (curr.equals(ObjectId.zeroId())) {
+      return RevisionNoteMap.emptyMap();
+    }
+    if (migration.readChanges()) {
+      // If reading from changes is enabled, then the old RobotCommentNotes
+      // already parsed the revision notes. We can reuse them as long as the ref
+      // hasn't advanced.
+      ChangeNotes changeNotes = getNotes();
+      if (changeNotes != null) {
+        RobotCommentNotes robotCommentNotes =
+            changeNotes.load().getRobotCommentNotes();
+        if (robotCommentNotes != null) {
+          ObjectId idFromNotes =
+              firstNonNull(robotCommentNotes.getRevision(), ObjectId.zeroId());
+          RevisionNoteMap<RobotCommentsRevisionNote> rnm =
+              robotCommentNotes.getRevisionNoteMap();
+          if (idFromNotes.equals(curr) && rnm != null) {
+            return rnm;
+          }
+        }
+      }
+    }
+    NoteMap noteMap;
+    if (!curr.equals(ObjectId.zeroId())) {
+      noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
+    } else {
+      noteMap = NoteMap.newEmptyMap();
+    }
+    // Even though reading from changes might not be enabled, we need to
+    // parse any existing revision notes so we can merge them.
+    return RevisionNoteMap.parseRobotComments(
+        noteUtil,
+        rw.getObjectReader(),
+        noteMap);
+  }
+
+  @Override
+  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins,
+      ObjectId curr) throws OrmException, IOException {
+    CommitBuilder cb = new CommitBuilder();
+    cb.setMessage("Update robot comments");
+    try {
+      return storeCommentsInNotes(rw, ins, curr, cb);
+    } catch (ConfigInvalidException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  @Override
+  protected Project.NameKey getProjectName() {
+    return getNotes().getProjectName();
+  }
+
+  @Override
+  protected String getRefName() {
+    return robotCommentsRef(getId());
+  }
+
+  @Override
+  public boolean isEmpty() {
+    return put.isEmpty();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
new file mode 100644
index 0000000..0dca408
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.reviewdb.client.RobotComment;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.List;
+
+public class RobotCommentsRevisionNote extends RevisionNote<RobotComment> {
+  private final ChangeNoteUtil noteUtil;
+
+  RobotCommentsRevisionNote(ChangeNoteUtil noteUtil, ObjectReader reader,
+      ObjectId noteId) {
+    super(reader, noteId);
+    this.noteUtil = noteUtil;
+  }
+
+  @Override
+  protected List<RobotComment> parse(byte[] raw, int offset)
+      throws IOException {
+    try (InputStream is = new ByteArrayInputStream(
+        raw, offset, raw.length - offset);
+        Reader r = new InputStreamReader(is, UTF_8)) {
+      return noteUtil.getGson().fromJson(r,
+          RobotCommentsRevisionNoteData.class).comments;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
index 9bcabc3..ea3a149 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RobotComment;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
+import java.util.List;
 
-  Address from(Account.Id fromId);
+public class RobotCommentsRevisionNoteData {
+  List<RobotComment> comments;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
index c0bb8ab..1c585fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableMultimap;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
@@ -27,7 +27,6 @@
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
 
 import java.io.IOException;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -81,23 +80,6 @@
   }
 
   @Override
-  public boolean rebuildProject(ReviewDb db,
-      ImmutableMultimap<Project.NameKey, Change.Id> allChanges,
-      Project.NameKey project, Repository allUsersRepo)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException {
-    if (failNextUpdate.getAndSet(false)) {
-      throw new IOException("Update failed");
-    }
-    boolean result =
-        delegate.rebuildProject(db, allChanges, project, allUsersRepo);
-    if (stealNextUpdate.getAndSet(false)) {
-      throw new IOException("Update stolen");
-    }
-    return result;
-  }
-
-  @Override
   public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
       throws NoSuchChangeException, IOException, OrmException {
     // Don't inspect stealNextUpdate; that happens in execute() below.
@@ -117,4 +99,11 @@
     }
     return result;
   }
+
+  @Override
+  public void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
+      throws IOException, OrmException {
+    // Don't check for manual failure; that happens in execute().
+    delegate.buildUpdates(manager, bundle);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java
index 9bcabc3..0e6d3e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.notedb.rebuild;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gwtorm.server.OrmRuntimeException;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
+class AbortUpdateException extends OrmRuntimeException {
+  private static final long serialVersionUID = 1L;
 
-  Address from(Account.Id fromId);
+  AbortUpdateException() {
+    super("aborted");
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
new file mode 100644
index 0000000..4fed25d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+
+import java.sql.Timestamp;
+
+class ApprovalEvent extends Event {
+  private PatchSetApproval psa;
+
+  ApprovalEvent(PatchSetApproval psa, Timestamp changeCreatedOn) {
+    super(psa.getPatchSetId(), psa.getAccountId(), psa.getRealAccountId(),
+        psa.getGranted(), changeCreatedOn, psa.getTag());
+    this.psa = psa;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return false;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) {
+    checkUpdate(update);
+    update.putApproval(psa.getLabel(), psa.getValue());
+  }
+
+  @Override
+  protected boolean isPostSubmitApproval() {
+    return psa.isPostSubmit();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
new file mode 100644
index 0000000..ed5cd8b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+
+import java.sql.Timestamp;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+class ChangeMessageEvent extends Event {
+  private static final Pattern TOPIC_SET_REGEXP =
+      Pattern.compile("^Topic set to (.+)$");
+  private static final Pattern TOPIC_CHANGED_REGEXP =
+      Pattern.compile("^Topic changed from (.+) to (.+)$");
+  private static final Pattern TOPIC_REMOVED_REGEXP =
+      Pattern.compile("^Topic (.+) removed$");
+
+  private final ChangeMessage message;
+  private final Change noteDbChange;
+
+  ChangeMessageEvent(ChangeMessage message, Change noteDbChange,
+      Timestamp changeCreatedOn) {
+    super(message.getPatchSetId(), message.getAuthor(), message.getRealAuthor(),
+        message.getWrittenOn(), changeCreatedOn, message.getTag());
+    this.message = message;
+    this.noteDbChange = noteDbChange;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return true;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) throws OrmException {
+    checkUpdate(update);
+    update.setChangeMessage(message.getMessage());
+    setTopic(update);
+  }
+
+  private void setTopic(ChangeUpdate update) {
+    String msg = message.getMessage();
+    if (msg == null) {
+      return;
+    }
+    Matcher m = TOPIC_SET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      String topic = m.group(1);
+      update.setTopic(topic);
+      noteDbChange.setTopic(topic);
+      return;
+    }
+
+    m = TOPIC_CHANGED_REGEXP.matcher(msg);
+    if (m.matches()) {
+      String topic = m.group(2);
+      update.setTopic(topic);
+      noteDbChange.setTopic(topic);
+      return;
+    }
+
+    if (TOPIC_REMOVED_REGEXP.matcher(msg).matches()) {
+      update.setTopic(null);
+      noteDbChange.setTopic(null);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
similarity index 85%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
index 679b5e2..a63fbad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
@@ -12,21 +12,20 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.notedb;
+package com.google.gerrit.server.notedb.rebuild;
 
-import com.google.common.collect.ImmutableMultimap;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
 
 import java.io.IOException;
 import java.util.concurrent.Callable;
@@ -67,11 +66,8 @@
       ChangeBundle bundle) throws NoSuchChangeException, IOException,
       OrmException, ConfigInvalidException;
 
-  public abstract boolean rebuildProject(ReviewDb db,
-      ImmutableMultimap<Project.NameKey, Change.Id> allChanges,
-      Project.NameKey project, Repository allUsersRepo)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException;
+  public abstract void buildUpdates(NoteDbUpdateManager manager,
+      ChangeBundle bundle) throws IOException, OrmException;
 
   public abstract NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
       throws NoSuchChangeException, IOException, OrmException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
new file mode 100644
index 0000000..b3aa420
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -0,0 +1,598 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Table;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.git.ChainedReceiveCommands;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.ChangeDraftUpdate;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.OpenRepo;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+public class ChangeRebuilderImpl extends ChangeRebuilder {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeRebuilderImpl.class);
+
+  /**
+   * The maximum amount of time between the ReviewDb timestamp of the first and
+   * last events batched together into a single NoteDb update.
+   * <p>
+   * Used to account for the fact that different records with their own
+   * timestamps (e.g. {@link PatchSetApproval} and {@link ChangeMessage})
+   * historically didn't necessarily use the same timestamp, and tended to call
+   * {@code System.currentTimeMillis()} independently.
+   */
+  public static final long MAX_WINDOW_MS = SECONDS.toMillis(3);
+
+  /**
+   * The maximum amount of time between two consecutive events to consider them
+   * to be in the same batch.
+   */
+  static final long MAX_DELTA_MS = SECONDS.toMillis(1);
+
+  private final AccountCache accountCache;
+  private final ChangeBundleReader bundleReader;
+  private final ChangeDraftUpdate.Factory draftUpdateFactory;
+  private final ChangeNoteUtil changeNoteUtil;
+  private final ChangeUpdate.Factory updateFactory;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final NotesMigration migration;
+  private final PatchListCache patchListCache;
+  private final PersonIdent serverIdent;
+  private final ProjectCache projectCache;
+  private final String anonymousCowardName;
+  private final String serverId;
+
+  @Inject
+  ChangeRebuilderImpl(SchemaFactory<ReviewDb> schemaFactory,
+      AccountCache accountCache,
+      ChangeBundleReader bundleReader,
+      ChangeDraftUpdate.Factory draftUpdateFactory,
+      ChangeNoteUtil changeNoteUtil,
+      ChangeUpdate.Factory updateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      NotesMigration migration,
+      PatchListCache patchListCache,
+      @GerritPersonIdent PersonIdent serverIdent,
+      @Nullable ProjectCache projectCache,
+      @AnonymousCowardName String anonymousCowardName,
+      @GerritServerId String serverId) {
+    super(schemaFactory);
+    this.accountCache = accountCache;
+    this.bundleReader = bundleReader;
+    this.draftUpdateFactory = draftUpdateFactory;
+    this.changeNoteUtil = changeNoteUtil;
+    this.updateFactory = updateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.migration = migration;
+    this.patchListCache = patchListCache;
+    this.serverIdent = serverIdent;
+    this.projectCache = projectCache;
+    this.anonymousCowardName = anonymousCowardName;
+    this.serverId = serverId;
+  }
+
+  @Override
+  public Result rebuild(ReviewDb db, Change.Id changeId)
+      throws NoSuchChangeException, IOException, OrmException,
+      ConfigInvalidException {
+    db = ReviewDbUtil.unwrapDb(db);
+    // Read change just to get project; this instance is then discarded so we
+    // can read a consistent ChangeBundle inside a transaction.
+    Change change = db.changes().get(changeId);
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+    try (NoteDbUpdateManager manager =
+        updateManagerFactory.create(change.getProject())) {
+      buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
+      return execute(db, changeId, manager);
+    }
+  }
+
+  @Override
+  public Result rebuild(NoteDbUpdateManager manager,
+      ChangeBundle bundle) throws NoSuchChangeException, IOException,
+      OrmException, ConfigInvalidException {
+    Change change = new Change(bundle.getChange());
+    buildUpdates(manager, bundle);
+    return manager.stageAndApplyDelta(change);
+  }
+
+  @Override
+  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
+      throws NoSuchChangeException, IOException, OrmException {
+    db = ReviewDbUtil.unwrapDb(db);
+    Change change = ChangeNotes.readOneReviewDbChange(db, changeId);
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+    NoteDbUpdateManager manager =
+        updateManagerFactory.create(change.getProject());
+    buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
+    manager.stage();
+    return manager;
+  }
+
+  @Override
+  public Result execute(ReviewDb db, Change.Id changeId,
+      NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
+      IOException {
+    db = ReviewDbUtil.unwrapDb(db);
+    Change change = ChangeNotes.readOneReviewDbChange(db, changeId);
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    final String oldNoteDbState = change.getNoteDbState();
+    Result r = manager.stageAndApplyDelta(change);
+    final String newNoteDbState = change.getNoteDbState();
+    try {
+      db.changes().atomicUpdate(changeId, new AtomicUpdate<Change>() {
+        @Override
+        public Change update(Change change) {
+          String currNoteDbState = change.getNoteDbState();
+          if (Objects.equals(currNoteDbState, newNoteDbState)) {
+            // Another thread completed the same rebuild we were about to.
+            throw new AbortUpdateException();
+          } else if (!Objects.equals(oldNoteDbState, currNoteDbState)) {
+            // Another thread updated the state to something else.
+            throw new ConflictingUpdateException(change, oldNoteDbState);
+          }
+          change.setNoteDbState(newNoteDbState);
+          return change;
+        }
+      });
+    } catch (ConflictingUpdateException e) {
+      // Rethrow as an OrmException so the caller knows to use staged results.
+      // Strictly speaking they are not completely up to date, but result we
+      // send to the caller is the same as if this rebuild had executed before
+      // the other thread.
+      throw new OrmException(e.getMessage());
+    } catch (AbortUpdateException e) {
+      if (NoteDbChangeState.parse(changeId, newNoteDbState).isUpToDate(
+          manager.getChangeRepo().cmds.getRepoRefCache(),
+          manager.getAllUsersRepo().cmds.getRepoRefCache())) {
+        // If the state in ReviewDb matches NoteDb at this point, it means
+        // another thread successfully completed this rebuild. It's ok to not
+        // execute the update in this case, since the object referenced in the
+        // Result was flushed to the repo by whatever thread won the race.
+        return r;
+      }
+      // If the state doesn't match, that means another thread attempted this
+      // rebuild, but failed. Fall through and try to update the ref again.
+    }
+    if (migration.failChangeWrites()) {
+      // Don't even attempt to execute if read-only, it would fail anyway. But
+      // do throw an exception to the caller so they know to use the staged
+      // results instead of reading from the repo.
+      throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
+    }
+    manager.execute();
+    return r;
+  }
+
+  @Override
+  public void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
+      throws IOException, OrmException {
+    manager.setCheckExpectedState(false);
+    Change change = new Change(bundle.getChange());
+    if (bundle.getPatchSets().isEmpty()) {
+      throw new NoPatchSetsException(change.getId());
+    }
+
+    PatchSet.Id currPsId = change.currentPatchSetId();
+    // We will rebuild all events, except for draft comments, in buckets based
+    // on author and timestamp.
+    List<Event> events = new ArrayList<>();
+    Multimap<Account.Id, DraftCommentEvent> draftCommentEvents =
+        ArrayListMultimap.create();
+
+    events.addAll(getHashtagsEvents(change, manager));
+
+    // Delete ref only after hashtags have been read
+    deleteChangeMetaRef(change, manager.getChangeRepo().cmds);
+    deleteDraftRefs(change, manager.getAllUsersRepo());
+
+    Integer minPsNum = getMinPatchSetNum(bundle);
+    Map<PatchSet.Id, PatchSetEvent> patchSetEvents =
+        Maps.newHashMapWithExpectedSize(bundle.getPatchSets().size());
+
+    for (PatchSet ps : bundle.getPatchSets()) {
+      if (ps.getId().get() > currPsId.get()) {
+        log.info(
+            "Skipping patch set {}, which is higher than current patch set {}",
+            ps.getId(), currPsId);
+        continue;
+      }
+      PatchSetEvent pse =
+          new PatchSetEvent(change, ps, manager.getChangeRepo().rw);
+      patchSetEvents.put(ps.getId(), pse);
+      events.add(pse);
+      for (Comment c : getComments(bundle, serverId, Status.PUBLISHED, ps)) {
+        CommentEvent e =
+            new CommentEvent(c, change, ps, patchListCache);
+        events.add(e.addDep(pse));
+      }
+      for (Comment c : getComments(bundle, serverId, Status.DRAFT, ps)) {
+        DraftCommentEvent e =
+            new DraftCommentEvent(c, change, ps, patchListCache);
+        draftCommentEvents.put(c.author.getId(), e);
+      }
+    }
+
+    for (PatchSetApproval psa : bundle.getPatchSetApprovals()) {
+      PatchSetEvent pse = patchSetEvents.get(psa.getPatchSetId());
+      if (pse != null) {
+        events.add(new ApprovalEvent(psa, change.getCreatedOn()).addDep(pse));
+      }
+    }
+
+    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> r :
+        bundle.getReviewers().asTable().cellSet()) {
+      events.add(new ReviewerEvent(r, change.getCreatedOn()));
+    }
+
+    Change noteDbChange = new Change(null, null, null, null, null);
+    for (ChangeMessage msg : bundle.getChangeMessages()) {
+      List<Event> msgEvents = parseChangeMessage(msg, change, noteDbChange);
+      if (msg.getPatchSetId() != null) {
+        PatchSetEvent pse = patchSetEvents.get(msg.getPatchSetId());
+        if (pse == null) {
+          continue; // Ignore events for missing patch sets.
+        }
+        for (Event e : msgEvents) {
+          e.addDep(pse);
+        }
+      }
+      events.addAll(msgEvents);
+    }
+
+    sortAndFillEvents(change, noteDbChange, events, minPsNum);
+
+    EventList<Event> el = new EventList<>();
+    for (Event e : events) {
+      if (!el.canAdd(e)) {
+        flushEventsToUpdate(manager, el, change);
+        checkState(el.canAdd(e));
+      }
+      el.add(e);
+    }
+    flushEventsToUpdate(manager, el, change);
+
+    EventList<DraftCommentEvent> plcel = new EventList<>();
+    for (Account.Id author : draftCommentEvents.keys()) {
+      for (DraftCommentEvent e :
+          Ordering.natural().sortedCopy(draftCommentEvents.get(author))) {
+        if (!plcel.canAdd(e)) {
+          flushEventsToDraftUpdate(manager, plcel, change);
+          checkState(plcel.canAdd(e));
+        }
+        plcel.add(e);
+      }
+      flushEventsToDraftUpdate(manager, plcel, change);
+    }
+  }
+
+  private List<Event> parseChangeMessage(ChangeMessage msg, Change change,
+      Change noteDbChange) {
+    List<Event> events = new ArrayList<>(2);
+    events.add(new ChangeMessageEvent(msg, noteDbChange, change.getCreatedOn()));
+    Optional<StatusChangeEvent> sce =
+        StatusChangeEvent.parseFromMessage(msg, change, noteDbChange);
+    if (sce.isPresent()) {
+      events.add(sce.get());
+    }
+    return events;
+  }
+
+  private static Integer getMinPatchSetNum(ChangeBundle bundle) {
+    Integer minPsNum = null;
+    for (PatchSet ps : bundle.getPatchSets()) {
+      int n = ps.getId().get();
+      if (minPsNum == null || n < minPsNum) {
+        minPsNum = n;
+      }
+    }
+    return minPsNum;
+  }
+
+  private static List<Comment> getComments(ChangeBundle bundle, String serverId,
+      PatchLineComment.Status status, PatchSet ps) {
+    return bundle.getPatchLineComments().stream()
+        .filter(c -> c.getPatchSetId().equals(ps.getId())
+            && c.getStatus() == status)
+        .map(plc -> plc.asComment(serverId)).sorted(CommentsUtil.COMMENT_ORDER)
+        .collect(toList());
+  }
+
+  private void sortAndFillEvents(Change change, Change noteDbChange,
+      List<Event> events, Integer minPsNum) {
+    Event finalUpdates = new FinalUpdatesEvent(change, noteDbChange);
+    events.add(finalUpdates);
+    setPostSubmitDeps(events);
+    new EventSorter(events).sort();
+
+    // Ensure the first event in the list creates the change, setting the author
+    // and any required footers.
+    Event first = events.get(0);
+    if (first instanceof PatchSetEvent
+        && change.getOwner().equals(first.user)) {
+      ((PatchSetEvent) first).createChange = true;
+    } else {
+      events.add(0, new CreateChangeEvent(change, minPsNum));
+    }
+
+    // Final pass to correct some inconsistencies.
+    //
+    // First, fill in any missing patch set IDs using the latest patch set of
+    // the change at the time of the event, because NoteDb can't represent
+    // actions with no associated patch set ID. This workaround is as if a user
+    // added a ChangeMessage on the change by replying from the latest patch
+    // set.
+    //
+    // Start with the first patch set that actually exists. If there are no
+    // patch sets at all, minPsNum will be null, so just bail and use 1 as the
+    // patch set ID. The corresponding patch set won't exist, but this change is
+    // probably corrupt anyway, as deleting the last draft patch set should have
+    // deleted the whole change.
+    //
+    // Second, ensure timestamps are nondecreasing, by copying the previous
+    // timestamp if this happens. This assumes that the only way this can happen
+    // is due to dependency constraints, and it is ok to give an event the same
+    // timestamp as one of its dependencies.
+    int ps = firstNonNull(minPsNum, 1);
+    for (int i = 0; i < events.size(); i++) {
+      Event e = events.get(i);
+      if (e.psId == null) {
+        e.psId = new PatchSet.Id(change.getId(), ps);
+      } else {
+        ps = Math.max(ps, e.psId.get());
+      }
+
+      if (i > 0) {
+        Event p = events.get(i - 1);
+        if (e.when.before(p.when)) {
+          e.when = p.when;
+        }
+      }
+    }
+  }
+
+  private void setPostSubmitDeps(List<Event> events) {
+    Optional<Event> submitEvent = Lists.reverse(events).stream()
+        .filter(Event::isSubmit)
+        .findFirst();
+    if (submitEvent.isPresent()) {
+      events.stream()
+          .filter(Event::isPostSubmitApproval)
+          .forEach(e -> e.addDep(submitEvent.get()));
+    }
+  }
+
+  private void flushEventsToUpdate(NoteDbUpdateManager manager,
+      EventList<Event> events, Change change) throws OrmException, IOException {
+    if (events.isEmpty()) {
+      return;
+    }
+    Comparator<String> labelNameComparator;
+    if (projectCache != null) {
+      labelNameComparator = projectCache.get(change.getProject())
+          .getLabelTypes().nameComparator();
+    } else {
+      // No project cache available, bail and use natural ordering; there's no
+      // semantic difference anyway difference.
+      labelNameComparator = Ordering.natural();
+    }
+    ChangeUpdate update = updateFactory.create(
+        change,
+        events.getAccountId(),
+        events.getRealAccountId(),
+        newAuthorIdent(events),
+        events.getWhen(),
+        labelNameComparator);
+    update.setAllowWriteToNewRef(true);
+    update.setPatchSetId(events.getPatchSetId());
+    update.setTag(events.getTag());
+    for (Event e : events) {
+      e.apply(update);
+    }
+    manager.add(update);
+    events.clear();
+  }
+
+  private void flushEventsToDraftUpdate(NoteDbUpdateManager manager,
+      EventList<DraftCommentEvent> events, Change change)
+      throws OrmException {
+    if (events.isEmpty()) {
+      return;
+    }
+    ChangeDraftUpdate update = draftUpdateFactory.create(
+        change,
+        events.getAccountId(),
+        events.getRealAccountId(),
+        newAuthorIdent(events),
+        events.getWhen());
+    update.setPatchSetId(events.getPatchSetId());
+    for (DraftCommentEvent e : events) {
+      e.applyDraft(update);
+    }
+    manager.add(update);
+    events.clear();
+  }
+
+  private PersonIdent newAuthorIdent(EventList<?> events) {
+    Account.Id id = events.getAccountId();
+    if (id == null) {
+      return new PersonIdent(serverIdent, events.getWhen());
+    }
+    return changeNoteUtil.newIdent(
+        accountCache.get(id).getAccount(), events.getWhen(), serverIdent,
+        anonymousCowardName);
+  }
+
+  private List<HashtagsEvent> getHashtagsEvents(Change change,
+      NoteDbUpdateManager manager) throws IOException {
+    String refName = changeMetaRef(change.getId());
+    Optional<ObjectId> old = manager.getChangeRepo().getObjectId(refName);
+    if (!old.isPresent()) {
+      return Collections.emptyList();
+    }
+
+    RevWalk rw = manager.getChangeRepo().rw;
+    List<HashtagsEvent> events = new ArrayList<>();
+    rw.reset();
+    rw.markStart(rw.parseCommit(old.get()));
+    for (RevCommit commit : rw) {
+      Account.Id authorId;
+      try {
+        authorId =
+            changeNoteUtil.parseIdent(commit.getAuthorIdent(), change.getId());
+      } catch (ConfigInvalidException e) {
+        continue; // Corrupt data, no valid hashtags in this commit.
+      }
+      PatchSet.Id psId = parsePatchSetId(change, commit);
+      Set<String> hashtags = parseHashtags(commit);
+      if (authorId == null || psId == null || hashtags == null) {
+        continue;
+      }
+
+      Timestamp commitTime =
+          new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+      events.add(new HashtagsEvent(psId, authorId, commitTime, hashtags,
+            change.getCreatedOn()));
+    }
+    return events;
+  }
+
+  private Set<String> parseHashtags(RevCommit commit) {
+    List<String> hashtagsLines = commit.getFooterLines(FOOTER_HASHTAGS);
+    if (hashtagsLines.isEmpty() || hashtagsLines.size() > 1) {
+      return null;
+    }
+
+    if (hashtagsLines.get(0).isEmpty()) {
+      return ImmutableSet.of();
+    }
+    return Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
+  }
+
+  private PatchSet.Id parsePatchSetId(Change change, RevCommit commit) {
+    List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
+    if (psIdLines.size() != 1) {
+      return null;
+    }
+    Integer psId = Ints.tryParse(psIdLines.get(0));
+    if (psId == null) {
+      return null;
+    }
+    return new PatchSet.Id(change.getId(), psId);
+  }
+
+  private void deleteChangeMetaRef(Change change, ChainedReceiveCommands cmds)
+      throws IOException {
+    String refName = changeMetaRef(change.getId());
+    Optional<ObjectId> old = cmds.get(refName);
+    if (old.isPresent()) {
+      cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), refName));
+    }
+  }
+
+  private void deleteDraftRefs(Change change, OpenRepo allUsersRepo)
+      throws IOException {
+    for (Ref r : allUsersRepo.repo.getRefDatabase()
+        .getRefs(RefNames.refsDraftCommentsPrefix(change.getId())).values()) {
+      allUsersRepo.cmds.add(
+          new ReceiveCommand(r.getObjectId(), ObjectId.zeroId(), r.getName()));
+    }
+  }
+
+  static void createChange(ChangeUpdate update, Change change) {
+    update.setSubjectForCommit("Create change");
+    update.setChangeId(change.getKey().get());
+    update.setBranch(change.getDest().get());
+    update.setSubject(change.getOriginalSubject());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
new file mode 100644
index 0000000..8f461a2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gwtorm.server.OrmException;
+
+class CommentEvent extends Event {
+  public final Comment c;
+  private final Change change;
+  private final PatchSet ps;
+  private final PatchListCache cache;
+
+  CommentEvent(Comment c, Change change, PatchSet ps,
+      PatchListCache cache) {
+    super(CommentsUtil.getCommentPsId(change.getId(), c), c.author.getId(),
+        c.getRealAuthor().getId(), c.writtenOn, change.getCreatedOn(), c.tag);
+    this.c = c;
+    this.change = change;
+    this.ps = ps;
+    this.cache = cache;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return false;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) throws OrmException {
+    checkUpdate(update);
+    if (c.revId == null) {
+      setCommentRevId(c, cache, change, ps);
+    }
+    update.putComment(PatchLineComment.Status.PUBLISHED, c);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
new file mode 100644
index 0000000..2098727
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmRuntimeException;
+
+class ConflictingUpdateException extends OrmRuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  ConflictingUpdateException(Change change, String expectedNoteDbState) {
+    super(String.format(
+        "Expected change %s to have noteDbState %s but was %s",
+        change.getId(), expectedNoteDbState, change.getNoteDbState()));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
new file mode 100644
index 0000000..b020911
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+
+import java.io.IOException;
+
+class CreateChangeEvent extends Event {
+  private final Change change;
+
+  private static PatchSet.Id psId(Change change, Integer minPsNum) {
+    int n;
+    if (minPsNum == null) {
+      // There were no patch sets for the change at all, so something is very
+      // wrong. Bail and use 1 as the patch set.
+      n = 1;
+    } else {
+      n = minPsNum;
+    }
+    return new PatchSet.Id(change.getId(), n);
+  }
+
+  CreateChangeEvent(Change change, Integer minPsNum) {
+    super(psId(change, minPsNum), change.getOwner(), change.getOwner(),
+        change.getCreatedOn(), change.getCreatedOn(), null);
+    this.change = change;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return true;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) throws IOException, OrmException {
+    checkUpdate(update);
+    ChangeRebuilderImpl.createChange(update, change);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
new file mode 100644
index 0000000..2938480
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeDraftUpdate;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gwtorm.server.OrmException;
+
+class DraftCommentEvent extends Event {
+  public final Comment c;
+  private final Change change;
+  private final PatchSet ps;
+  private final PatchListCache cache;
+
+  DraftCommentEvent(Comment c, Change change, PatchSet ps,
+      PatchListCache cache) {
+    super(CommentsUtil.getCommentPsId(change.getId(), c), c.author.getId(),
+        c.getRealAuthor().getId(), c.writtenOn, change.getCreatedOn(), c.tag);
+    this.c = c;
+    this.change = change;
+    this.ps = ps;
+    this.cache = cache;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return false;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) {
+    throw new UnsupportedOperationException();
+  }
+
+  void applyDraft(ChangeDraftUpdate draftUpdate) throws OrmException {
+    if (c.revId == null) {
+      setCommentRevId(c, cache, change, ps);
+    }
+    draftUpdate.putComment(c);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java
new file mode 100644
index 0000000..147a467
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl.MAX_WINDOW_MS;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ComparisonChain;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.notedb.AbstractChangeUpdate;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+abstract class Event implements Comparable<Event> {
+  // NOTE: EventList only supports direct subclasses, not an arbitrary
+  // hierarchy.
+
+  final Account.Id user;
+  final Account.Id realUser;
+  final String tag;
+  final boolean predatesChange;
+
+  /**
+   * Dependencies of this event; other events that must happen before this
+   * one.
+   */
+  final List<Event> deps;
+
+  Timestamp when;
+  PatchSet.Id psId;
+
+  protected Event(
+      PatchSet.Id psId,
+      Account.Id effectiveUser,
+      Account.Id realUser,
+      Timestamp when,
+      Timestamp changeCreatedOn,
+      String tag) {
+    this.psId = psId;
+    this.user = effectiveUser;
+    this.realUser = realUser != null ? realUser : effectiveUser;
+    this.tag = tag;
+    // Truncate timestamps at the change's createdOn timestamp.
+    predatesChange = when.before(changeCreatedOn);
+    this.when = predatesChange ? changeCreatedOn : when;
+    deps = new ArrayList<>();
+  }
+
+  protected void checkUpdate(AbstractChangeUpdate update) {
+    checkState(Objects.equals(update.getPatchSetId(), psId),
+        "cannot apply event for %s to update for %s",
+        update.getPatchSetId(), psId);
+    checkState(when.getTime() - update.getWhen().getTime() <= MAX_WINDOW_MS,
+        "event at %s outside update window starting at %s",
+        when, update.getWhen());
+    checkState(Objects.equals(update.getNullableAccountId(), user),
+        "cannot apply event by %s to update by %s",
+        user, update.getNullableAccountId());
+  }
+
+  Event addDep(Event e) {
+    deps.add(e);
+    return this;
+  }
+
+  /**
+   * @return whether this event type must be unique per {@link ChangeUpdate},
+   *     i.e. there may be at most one of this type.
+   */
+  abstract boolean uniquePerUpdate();
+
+  abstract void apply(ChangeUpdate update) throws OrmException, IOException;
+
+  protected boolean isPostSubmitApproval() {
+    return false;
+  }
+
+  protected boolean isSubmit() {
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("psId", psId)
+        .add("effectiveUser", user)
+        .add("realUser", realUser)
+        .add("when", when)
+        .toString();
+  }
+
+  @Override
+  public int compareTo(Event other) {
+    return ComparisonChain.start()
+        .compareFalseFirst(this.isFinalUpdates(), other.isFinalUpdates())
+        .compare(this.when, other.when)
+        .compareTrueFirst(isPatchSet(), isPatchSet())
+        .compareTrueFirst(this.predatesChange, other.predatesChange)
+        .compare(this.user, other.user,
+            ReviewDbUtil.intKeyOrdering())
+        .compare(this.realUser, other.realUser, ReviewDbUtil.intKeyOrdering())
+        .compare(this.psId, other.psId,
+            ReviewDbUtil.intKeyOrdering().nullsLast())
+        .result();
+  }
+
+  private boolean isPatchSet() {
+    return this instanceof PatchSetEvent;
+  }
+
+  private boolean isFinalUpdates() {
+    return this instanceof FinalUpdatesEvent;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventList.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventList.java
new file mode 100644
index 0000000..59ff49e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventList.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.Objects;
+
+class EventList<E extends Event> implements Iterable<E> {
+  private final ArrayList<E> list = new ArrayList<>();
+  private boolean isSubmit;
+
+  @Override
+  public Iterator<E> iterator() {
+    return list.iterator();
+  }
+
+  void add(E e) {
+    list.add(e);
+    if (e.isSubmit()) {
+      isSubmit = true;
+    }
+  }
+
+  void clear() {
+    list.clear();
+    isSubmit = false;
+  }
+
+  boolean isEmpty() {
+    return list.isEmpty();
+  }
+
+  boolean canAdd(E e) {
+    if (isEmpty()) {
+      return true;
+    }
+    if (e instanceof FinalUpdatesEvent) {
+      return false; // FinalUpdatesEvent always gets its own update.
+    }
+
+    Event last = getLast();
+    if (!Objects.equals(e.user, last.user)
+        || !Objects.equals(e.realUser, last.realUser)
+        || !e.psId.equals(last.psId)
+        || !Objects.equals(e.tag, last.tag)) {
+      return false; // Different patch set, author, or tag.
+    }
+    if (e.isPostSubmitApproval() && isSubmit) {
+      // Post-submit approvals must come after the update that submits.
+      return false;
+    }
+
+    long t = e.when.getTime();
+    long tFirst = getFirstTime();
+    long tLast = getLastTime();
+    checkArgument(t >= tLast,
+        "event %s is before previous event in list %s", e, last);
+    if (t - tLast > ChangeRebuilderImpl.MAX_DELTA_MS || t - tFirst > ChangeRebuilderImpl.MAX_WINDOW_MS) {
+      return false; // Too much time elapsed.
+    }
+
+    if (!e.uniquePerUpdate()) {
+      return true;
+    }
+    for (Event o : this) {
+      if (e.getClass() == o.getClass()) {
+        return false; // Only one event of this type allowed per update.
+      }
+    }
+
+    // TODO(dborowitz): Additional heuristics, like keeping events separate if
+    // they affect overlapping fields within a single entity.
+
+    return true;
+  }
+
+  Timestamp getWhen() {
+    return get(0).when;
+  }
+
+  PatchSet.Id getPatchSetId() {
+    PatchSet.Id id = checkNotNull(get(0).psId);
+    for (int i = 1; i < size(); i++) {
+      checkState(get(i).psId.equals(id),
+          "mismatched patch sets in EventList: %s != %s", id, get(i).psId);
+    }
+    return id;
+  }
+
+  Account.Id getAccountId() {
+    Account.Id id = get(0).user;
+    for (int i = 1; i < size(); i++) {
+      checkState(Objects.equals(id, get(i).user),
+          "mismatched users in EventList: %s != %s", id, get(i).user);
+    }
+    return id;
+  }
+
+  Account.Id getRealAccountId() {
+    Account.Id id = get(0).realUser;
+    for (int i = 1; i < size(); i++) {
+      checkState(Objects.equals(id, get(i).realUser),
+          "mismatched real users in EventList: %s != %s", id, get(i).realUser);
+    }
+    return id;
+  }
+
+  String getTag() {
+    return getLast().tag;
+  }
+
+  private E get(int i) {
+    return list.get(i);
+  }
+
+  private int size() {
+    return list.size();
+  }
+
+  private E getLast() {
+    return list.get(list.size() - 1);
+  }
+
+  private long getLastTime() {
+    return getLast().when.getTime();
+  }
+
+  private long getFirstTime() {
+    return list.get(0).when.getTime();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java
new file mode 100644
index 0000000..69587f4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.SetMultimap;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.PriorityQueue;
+
+/**
+ * Helper to sort a list of events.
+ * <p>
+ * Events are sorted in two passes:
+ * <ol>
+ * <li>Sort by natural order (timestamp, patch set, author, etc.)</li>
+ * <li>Postpone any events with dependencies to occur only after all of their
+ *   dependencies, where this violates natural order.</li>
+ * </ol>
+ *
+ * {@link #sort()} modifies the event list in place (similar to {@link
+ * Collections#sort(List)}), but does not modify any event. In particular,
+ * events might end up out of order with respect to timestamp; callers are
+ * responsible for adjusting timestamps later if they prefer monotonicity.
+ */
+class EventSorter {
+  private final List<Event> out;
+  private final LinkedHashSet<Event> sorted;
+  private ListMultimap<Event, Event> waiting;
+  private SetMultimap<Event, Event> deps;
+
+  EventSorter(List<Event> events) {
+    LinkedHashSet<Event> all = new LinkedHashSet<>(events);
+    out = events;
+
+    for (Event e : events) {
+      for (Event d : e.deps) {
+        checkArgument(all.contains(d), "dep %s of %s not in input list", d, e);
+      }
+    }
+
+    all.clear();
+    sorted = all; // Presized.
+  }
+
+  void sort() {
+    // First pass: sort by natural order.
+    PriorityQueue<Event> todo = new PriorityQueue<>(out);
+
+    // Populate waiting map after initial sort to preserve natural order.
+    waiting = ArrayListMultimap.create();
+    deps = HashMultimap.create();
+    for (Event e : todo) {
+      for (Event d : e.deps) {
+        deps.put(e, d);
+        waiting.put(d, e);
+      }
+    }
+
+    // Second pass: enforce dependencies.
+    int size = out.size();
+    while (!todo.isEmpty()) {
+      process(todo.remove(), todo);
+    }
+    checkState(sorted.size() == size,
+        "event sort expected %s elements, got %s", size, sorted.size());
+
+    // Modify out in-place a la Collections#sort.
+    out.clear();
+    out.addAll(sorted);
+  }
+
+  void process(Event e, PriorityQueue<Event> todo) {
+    if (sorted.contains(e)) {
+      return; // Already emitted.
+    }
+    if (!deps.get(e).isEmpty()) {
+      // Not all events that e depends on have been emitted yet. Ignore e for
+      // now; it will get added back to the queue in the block below once its
+      // last dependency is processed.
+      return;
+    }
+
+    // All events that e depends on have been emitted, so e can be emitted.
+    sorted.add(e);
+
+    // Remove e from the dependency set of all events waiting on e, and add
+    // those events back to the queue in the original priority order for
+    // reconsideration.
+    for (Event w : waiting.get(e)) {
+      deps.get(w).remove(e);
+      todo.add(w);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
new file mode 100644
index 0000000..17cf8ba
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+
+import java.util.Objects;
+
+class FinalUpdatesEvent extends Event {
+  private final Change change;
+  private final Change noteDbChange;
+
+  FinalUpdatesEvent(Change change, Change noteDbChange) {
+    super(change.currentPatchSetId(), change.getOwner(), change.getOwner(),
+        change.getLastUpdatedOn(), change.getCreatedOn(), null);
+    this.change = change;
+    this.noteDbChange = noteDbChange;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return true;
+  }
+
+  @SuppressWarnings("deprecation")
+  @Override
+  void apply(ChangeUpdate update) throws OrmException {
+    if (!Objects.equals(change.getTopic(), noteDbChange.getTopic())) {
+      update.setTopic(change.getTopic());
+    }
+    if (!Objects.equals(change.getStatus(), noteDbChange.getStatus())) {
+      // TODO(dborowitz): Stamp approximate approvals at this time.
+      update.fixStatus(change.getStatus());
+    }
+    if (change.getSubmissionId() != null
+        && noteDbChange.getSubmissionId() == null) {
+      update.setSubmissionId(change.getSubmissionId());
+    }
+    if (!Objects.equals(change.getAssignee(), noteDbChange.getAssignee())) {
+      // TODO(dborowitz): Parse intermediate values out from messages.
+      update.setAssignee(change.getAssignee());
+    }
+    if (!update.isEmpty()) {
+      update.setSubjectForCommit("Final NoteDb migration updates");
+    }
+  }
+
+  @Override
+  protected boolean isSubmit() {
+    return change.getStatus() == Change.Status.MERGED;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
new file mode 100644
index 0000000..f5bea3e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+
+import java.sql.Timestamp;
+import java.util.Set;
+
+class HashtagsEvent extends Event {
+  private final Set<String> hashtags;
+
+  HashtagsEvent(PatchSet.Id psId, Account.Id who, Timestamp when,
+      Set<String> hashtags, Timestamp changeCreatdOn) {
+    super(psId, who, who, when, changeCreatdOn,
+        // Somewhat confusingly, hashtags do not use the setTag method on
+        // AbstractChangeUpdate, so pass null as the tag.
+        null);
+    this.hashtags = hashtags;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    // Since these are produced from existing commits in the old NoteDb graph,
+    // we know that there must be one per commit in the rebuilt graph.
+    return true;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) throws OrmException {
+    update.setHashtags(hashtags);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
new file mode 100644
index 0000000..abac1b0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.PatchSetState;
+import com.google.gwtorm.server.OrmException;
+
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.List;
+
+class PatchSetEvent extends Event {
+  private final Change change;
+  private final PatchSet ps;
+  private final RevWalk rw;
+  boolean createChange;
+
+  PatchSetEvent(Change change, PatchSet ps, RevWalk rw) {
+    super(ps.getId(), ps.getUploader(), ps.getUploader(), ps.getCreatedOn(),
+        change.getCreatedOn(), null);
+    this.change = change;
+    this.ps = ps;
+    this.rw = rw;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return true;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) throws IOException, OrmException {
+    checkUpdate(update);
+    if (createChange) {
+      ChangeRebuilderImpl.createChange(update, change);
+    } else {
+      update.setSubject(change.getSubject());
+      update.setSubjectForCommit("Create patch set " + ps.getPatchSetId());
+    }
+    setRevision(update, ps);
+    List<String> groups = ps.getGroups();
+    if (!groups.isEmpty()) {
+      update.setGroups(ps.getGroups());
+    }
+    if (ps.isDraft()) {
+      update.setPatchSetState(PatchSetState.DRAFT);
+    }
+  }
+
+  private void setRevision(ChangeUpdate update, PatchSet ps)
+      throws IOException {
+    String rev = ps.getRevision().get();
+    String cert = ps.getPushCertificate();
+    ObjectId id;
+    try {
+      id = ObjectId.fromString(rev);
+    } catch (InvalidObjectIdException e) {
+      update.setRevisionForMissingCommit(rev, cert);
+      return;
+    }
+    try {
+      update.setCommit(rw, id, cert);
+    } catch (MissingObjectException e) {
+      update.setRevisionForMissingCommit(rev, cert);
+      return;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
new file mode 100644
index 0000000..c82f108
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.common.collect.Table;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gwtorm.server.OrmException;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+
+class ReviewerEvent extends Event {
+  private Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer;
+
+  ReviewerEvent(
+      Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer,
+      Timestamp changeCreatedOn) {
+    super(
+        // Reviewers aren't generally associated with a particular patch set
+        // (although as an implementation detail they were in ReviewDb). Just
+        // use the latest patch set at the time of the event.
+        null,
+        reviewer.getColumnKey(),
+        // TODO(dborowitz): Real account ID shouldn't really matter for
+        // reviewers, but we might have to deal with this to avoid ChangeBundle
+        // diffs when run against real data.
+        reviewer.getColumnKey(),
+        reviewer.getValue(),
+        changeCreatedOn, null);
+    this.reviewer = reviewer;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return false;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) throws IOException, OrmException {
+    checkUpdate(update);
+    update.putReviewer(reviewer.getColumnKey(), reviewer.getRowKey());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/StatusChangeEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/StatusChangeEvent.java
new file mode 100644
index 0000000..5bc05d0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/StatusChangeEvent.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+
+import java.sql.Timestamp;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+class StatusChangeEvent extends Event {
+  private static final ImmutableMap<Change.Status, Pattern> PATTERNS =
+      ImmutableMap.of(
+          Change.Status.ABANDONED, Pattern.compile("^Abandoned(\n.*)*$"),
+          Change.Status.MERGED, Pattern.compile(
+              "^Change has been successfully"
+              + " (merged|cherry-picked|rebased|pushed).*$"),
+          Change.Status.NEW, Pattern.compile("^Restored(\n.*)*$"));
+
+  static Optional<StatusChangeEvent> parseFromMessage(ChangeMessage message,
+      Change change, Change noteDbChange) {
+    String msg = message.getMessage();
+    if (msg == null) {
+      return Optional.empty();
+    }
+    for (Map.Entry<Change.Status, Pattern> e : PATTERNS.entrySet()) {
+      if (e.getValue().matcher(msg).matches()) {
+        return Optional.of(new StatusChangeEvent(
+            message, change, noteDbChange, e.getKey()));
+      }
+    }
+    return Optional.empty();
+  }
+
+  private final Change.Status status;
+  private final Change change;
+  private final Change noteDbChange;
+
+  private StatusChangeEvent(ChangeMessage message, Change change,
+      Change noteDbChange, Change.Status status) {
+    this(message.getPatchSetId(), message.getAuthor(),
+        message.getWrittenOn(), change, noteDbChange, message.getTag(),
+        status);
+  }
+
+  private StatusChangeEvent(PatchSet.Id psId, Account.Id author,
+      Timestamp when, Change change, Change noteDbChange,
+      String tag, Change.Status status) {
+    super(psId, author, author, when, change.getCreatedOn(), tag);
+    this.change = change;
+    this.noteDbChange = noteDbChange;
+    this.status = status;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return true;
+  }
+
+  @SuppressWarnings("deprecation")
+  @Override
+  void apply(ChangeUpdate update) throws OrmException {
+    checkUpdate(update);
+    update.fixStatus(status);
+    noteDbChange.setStatus(status);
+    if (status == Change.Status.MERGED) {
+      update.setSubmissionId(change.getSubmissionId());
+      noteDbChange.setSubmissionId(change.getSubmissionId());
+    }
+  }
+
+  @Override
+  protected boolean isSubmit() {
+    return status == Change.Status.MERGED;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
index c4af9fd..aa8879a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -57,7 +57,7 @@
 public class AutoMerger {
   private static final Logger log = LoggerFactory.getLogger(AutoMerger.class);
 
-  static boolean cacheAutomerge(Config cfg) {
+  public static boolean cacheAutomerge(Config cfg) {
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
   }
 
@@ -96,11 +96,7 @@
     }
 
     rw.parseHeaders(merge);
-    String hash = merge.name();
-    String refName = RefNames.REFS_CACHE_AUTOMERGE
-        + hash.substring(0, 2)
-        + "/"
-        + hash.substring(2);
+    String refName = RefNames.refsCacheAutomerge(merge.name());
     Ref ref = repo.getRefDatabase().exactRef(refName);
     if (ref != null && ref.getObjectId() != null) {
       RevObject obj = rw.parseAny(ref.getObjectId());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/ComparisonType.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/ComparisonType.java
new file mode 100644
index 0000000..abbb680
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
+import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+public class ComparisonType {
+
+  /** 1-based parent */
+  private final Integer parentNum;
+
+  private final boolean autoMerge;
+
+  public static ComparisonType againstOtherPatchSet() {
+    return new ComparisonType(null, false);
+  }
+
+  public static ComparisonType againstParent(int parentNum) {
+    return new ComparisonType(parentNum, false);
+  }
+
+  public static ComparisonType againstAutoMerge() {
+    return new ComparisonType(null, true);
+  }
+
+  private ComparisonType(Integer parentNum, boolean autoMerge) {
+    this.parentNum = parentNum;
+    this.autoMerge = autoMerge;
+  }
+
+  public boolean isAgainstParentOrAutoMerge() {
+    return isAgainstParent() || isAgainstAutoMerge();
+  }
+
+  public boolean isAgainstParent() {
+    return parentNum != null;
+  }
+
+  public boolean isAgainstAutoMerge() {
+    return autoMerge;
+  }
+
+  public int getParentNum() {
+    checkNotNull(parentNum);
+    return parentNum;
+  }
+
+  void writeTo(OutputStream out) throws IOException {
+    writeVarInt32(out, parentNum != null ? parentNum : 0);
+    writeVarInt32(out, autoMerge ? 1 : 0);
+  }
+
+  static ComparisonType readFrom(InputStream in) throws IOException {
+    int p = readVarInt32(in);
+    Integer parentNum = p > 0 ? p : null;
+    boolean autoMerge = readVarInt32(in) != 0;
+    return new ComparisonType(parentNum, autoMerge);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummary.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummary.java
new file mode 100644
index 0000000..ae4589f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummary.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.gerrit.server.ioutil.BasicSerialization.readString;
+import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
+import static com.google.gerrit.server.ioutil.BasicSerialization.writeString;
+import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.zip.DeflaterOutputStream;
+import java.util.zip.InflaterInputStream;
+
+public class DiffSummary implements Serializable {
+  private static final long serialVersionUID = DiffSummaryKey.serialVersionUID;
+
+  private transient String[] paths;
+
+  public DiffSummary(String[] paths) {
+    this.paths = paths;
+  }
+
+  public List<String> getPaths() {
+    return Collections.unmodifiableList(Arrays.asList(paths));
+  }
+
+  private void writeObject(ObjectOutputStream output) throws IOException {
+    writeVarInt32(output, paths.length);
+    try (DeflaterOutputStream out = new DeflaterOutputStream(output)) {
+      for (String p : paths) {
+        writeString(out, p);
+      }
+    }
+  }
+
+  private void readObject(ObjectInputStream input) throws IOException {
+    paths = new String[readVarInt32(input)];
+    try (InflaterInputStream in = new InflaterInputStream(input)) {
+      for (int i = 0; i < paths.length; i++) {
+        paths[i] = readString(in);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java
new file mode 100644
index 0000000..4c708c4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static org.eclipse.jgit.lib.ObjectIdSerialization.readCanBeNull;
+import static org.eclipse.jgit.lib.ObjectIdSerialization.readNotNull;
+import static org.eclipse.jgit.lib.ObjectIdSerialization.writeCanBeNull;
+import static org.eclipse.jgit.lib.ObjectIdSerialization.writeNotNull;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+
+import org.eclipse.jgit.lib.ObjectId;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.Objects;
+
+public class DiffSummaryKey implements Serializable {
+  public static final long serialVersionUID = 1L;
+
+  /** see PatchListKey#oldId */
+  private transient ObjectId oldId;
+
+  /** see PatchListKey#parentNum */
+  private transient Integer parentNum;
+
+  private transient ObjectId newId;
+  private transient Whitespace whitespace;
+
+  public static DiffSummaryKey fromPatchListKey(PatchListKey plk) {
+    return new DiffSummaryKey(plk.getOldId(), plk.getParentNum(),
+        plk.getNewId(), plk.getWhitespace());
+  }
+
+  private DiffSummaryKey(ObjectId oldId, Integer parentNum, ObjectId newId,
+      Whitespace whitespace) {
+    this.oldId = oldId;
+    this.parentNum = parentNum;
+    this.newId = newId;
+    this.whitespace = whitespace;
+  }
+
+  PatchListKey toPatchListKey() {
+    return new PatchListKey(oldId, parentNum, newId, whitespace);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(oldId, parentNum, newId, whitespace);
+  }
+
+  @Override
+  public boolean equals(final Object o) {
+    if (o instanceof DiffSummaryKey) {
+      DiffSummaryKey k = (DiffSummaryKey) o;
+      return Objects.equals(oldId, k.oldId)
+          && Objects.equals(parentNum, k.parentNum)
+          && Objects.equals(newId, k.newId)
+          && whitespace == k.whitespace;
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder n = new StringBuilder();
+    n.append("DiffSummaryKey[");
+    n.append(oldId != null ? oldId.name() : "BASE");
+    n.append("..");
+    n.append(newId.name());
+    n.append(" ");
+    if (parentNum != null) {
+      n.append(parentNum);
+      n.append(" ");
+    }
+    n.append(whitespace.name());
+    n.append("]");
+    return n.toString();
+  }
+
+  private void writeObject(final ObjectOutputStream out) throws IOException {
+    writeCanBeNull(out, oldId);
+    out.writeInt(parentNum == null ? 0 : parentNum);
+    writeNotNull(out, newId);
+    Character c = PatchListKey.WHITESPACE_TYPES.get(whitespace);
+    if (c == null) {
+      throw new IOException("Invalid whitespace type: " + whitespace);
+    }
+    out.writeChar(c);
+  }
+
+  private void readObject(final ObjectInputStream in) throws IOException {
+    oldId = readCanBeNull(in);
+    int n = in.readInt();
+    parentNum = n == 0 ? null : Integer.valueOf(n);
+    newId = readNotNull(in);
+    char t = in.readChar();
+    whitespace = PatchListKey.WHITESPACE_TYPES.inverse().get(t);
+    if (whitespace == null) {
+      throw new IOException("Invalid whitespace type code: " + t);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
new file mode 100644
index 0000000..43e2392
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+public class DiffSummaryLoader implements Callable<DiffSummary> {
+  static final Logger log = LoggerFactory.getLogger(DiffSummaryLoader.class);
+
+  public interface Factory {
+    DiffSummaryLoader create(DiffSummaryKey key, Project.NameKey project);
+  }
+
+  private final PatchListCache patchListCache;
+  private final DiffSummaryKey key;
+  private final Project.NameKey project;
+
+  @AssistedInject
+  DiffSummaryLoader(PatchListCache plc,
+      @Assisted DiffSummaryKey k,
+      @Assisted Project.NameKey p) {
+    patchListCache = plc;
+    key = k;
+    project = p;
+  }
+
+  @Override
+  public DiffSummary call() throws Exception {
+    PatchList patchList = patchListCache.get(key.toPatchListKey(), project);
+    return toDiffSummary(patchList);
+  }
+
+  static DiffSummary toDiffSummary(PatchList patchList) {
+    List<String> r = new ArrayList<>(patchList.getPatches().size());
+    for (PatchListEntry e : patchList.getPatches()) {
+      if (Patch.isMagic(e.getNewName())) {
+        continue;
+      }
+      switch (e.getChangeType()) {
+        case ADDED:
+        case MODIFIED:
+        case DELETED:
+        case COPIED:
+        case REWRITE:
+          r.add(e.getNewName());
+          break;
+
+        case RENAMED:
+          r.add(e.getOldName());
+          r.add(e.getNewName());
+          break;
+      }
+    }
+    Collections.sort(r);
+    return new DiffSummary(r.toArray(new String[r.size()]));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java
new file mode 100644
index 0000000..548f999
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.common.cache.Weigher;
+
+/** Computes memory usage for {@link DiffSummary} in bytes of memory used. */
+public class DiffSummaryWeigher implements
+    Weigher<DiffSummaryKey, DiffSummary> {
+
+  @Override
+  public int weigh(DiffSummaryKey key, DiffSummary value) {
+    int size = 16 + 4 * 8 + 2 * 36 // Size of DiffSummaryKey, 64 bit JVM
+        + 16 + 8 // Size of DiffSummary
+        + 16 + 8; // String[]
+    for (String p : value.getPaths()) {
+      size += 16 + 8 + 4 * 4 // String
+          + 16 + 8 + p.length() * 2; // char[]
+    }
+    return size;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
index dd15cfc..ae37c01 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -93,7 +93,7 @@
     } catch (ExecutionException e) {
       // If there was an error computing the result, carry it
       // up to the caller so the cache knows this key is invalid.
-      Throwables.propagateIfInstanceOf(e.getCause(), Exception.class);
+      Throwables.throwIfInstanceOf(e.getCause(), Exception.class);
       throw new Exception(e.getMessage(), e.getCause());
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/MergeListBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/MergeListBuilder.java
new file mode 100644
index 0000000..8f54e48
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/MergeListBuilder.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.common.collect.ImmutableList;
+
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+public class MergeListBuilder {
+  public static List<RevCommit> build(RevWalk rw, RevCommit merge,
+      int uninterestingParent) throws IOException {
+    rw.reset();
+    rw.parseBody(merge);
+    if (merge.getParentCount() < 2) {
+      return ImmutableList.of();
+    }
+
+    for (int parent = 0; parent < merge.getParentCount(); parent++) {
+      RevCommit parentCommit = merge.getParent(parent);
+      rw.parseBody(parentCommit);
+      if (parent == uninterestingParent - 1) {
+        rw.markUninteresting(parentCommit);
+      } else {
+        rw.markStart(parentCommit);
+      }
+    }
+
+    List<RevCommit> result = new ArrayList<>();
+    RevCommit c;
+    while ((c = rw.next()) != null) {
+      result.add(c);
+    }
+    return result;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
index e570b3a..d2a6d2b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.patch;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.google.gerrit.common.errors.NoSuchEntityException;
 import com.google.gerrit.reviewdb.client.Patch;
 
@@ -42,9 +44,8 @@
   private Text a;
   private Text b;
 
-  public PatchFile(final Repository repo, final PatchList patchList,
-      final String fileName) throws MissingObjectException,
-      IncorrectObjectTypeException, IOException {
+  public PatchFile(Repository repo, PatchList patchList, String fileName)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
     this.repo = repo;
     this.entry = patchList.get(fileName);
 
@@ -53,7 +54,7 @@
       final RevCommit bCommit = rw.parseCommit(patchList.getNewId());
 
       if (Patch.COMMIT_MSG.equals(fileName)) {
-        if (patchList.isAgainstParent()) {
+        if (patchList.getComparisonType().isAgainstParentOrAutoMerge()) {
           a = Text.EMPTY;
         } else {
           // For the initial commit, we have an empty tree on Side A
@@ -66,7 +67,16 @@
 
         aTree = null;
         bTree = null;
+      } else if (Patch.MERGE_LIST.equals(fileName)) {
+        // For the initial commit, we have an empty tree on Side A
+        RevObject object = rw.parseAny(patchList.getOldId());
+        a = object instanceof RevCommit
+            ? Text.forMergeList(patchList.getComparisonType(), reader, object)
+            : Text.EMPTY;
+        b = Text.forMergeList(patchList.getComparisonType(), reader, bCommit);
 
+        aTree = null;
+        bTree = null;
       } else {
         if (patchList.getOldId() != null) {
           aTree = rw.parseTree(patchList.getOldId());
@@ -151,7 +161,7 @@
       return new Text(repo.open(tw.getObjectId(0), Constants.OBJ_BLOB));
     } else if (tw.getFileMode(0).getObjectType() == Constants.OBJ_COMMIT) {
       String str = "Subproject commit " + ObjectId.toString(tw.getObjectId(0));
-      return new Text(str.getBytes());
+      return new Text(str.getBytes(UTF_8));
     } else {
       return Text.EMPTY;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
index 2a4afb3..2cfd007 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
@@ -58,16 +58,19 @@
   @Nullable
   private transient ObjectId oldId;
   private transient ObjectId newId;
-  private transient boolean againstParent;
+  private transient boolean isMerge;
+  private transient ComparisonType comparisonType;
   private transient int insertions;
   private transient int deletions;
   private transient PatchListEntry[] patches;
 
-  public PatchList(@Nullable final AnyObjectId oldId, final AnyObjectId newId,
-      final boolean againstParent, final PatchListEntry[] patches) {
+  public PatchList(@Nullable AnyObjectId oldId, AnyObjectId newId,
+      boolean isMerge, ComparisonType comparisonType,
+      PatchListEntry[] patches) {
     this.oldId = oldId != null ? oldId.copy() : null;
     this.newId = newId.copy();
-    this.againstParent = againstParent;
+    this.isMerge = isMerge;
+    this.comparisonType = comparisonType;
 
     // We assume index 0 contains the magic commit message entry.
     if (patches.length > 1) {
@@ -97,9 +100,9 @@
     return Collections.unmodifiableList(Arrays.asList(patches));
   }
 
-  /** @return true if {@link #getOldId} is {@link #getNewId}'s ancestor. */
-  public boolean isAgainstParent() {
-    return againstParent;
+  /** @return the comparison type */
+  public ComparisonType getComparisonType() {
+    return comparisonType;
   }
 
   /** @return total number of new lines added. */
@@ -144,9 +147,12 @@
     if (Patch.COMMIT_MSG.equals(fileName)) {
       return 0;
     }
+    if (isMerge && Patch.MERGE_LIST.equals(fileName)) {
+      return 1;
+    }
 
     int high = patches.length;
-    int low = 1;
+    int low = isMerge ? 2 : 1;
     while (low < high) {
       final int mid = (low + high) >>> 1;
       final int cmp = patches[mid].getNewName().compareTo(fileName);
@@ -166,7 +172,8 @@
     try (DeflaterOutputStream out = new DeflaterOutputStream(buf)) {
       writeCanBeNull(out, oldId);
       writeNotNull(out, newId);
-      writeVarInt32(out, againstParent ? 1 : 0);
+      writeVarInt32(out, isMerge ? 1 : 0);
+      comparisonType.writeTo(out);
       writeVarInt32(out, insertions);
       writeVarInt32(out, deletions);
       writeVarInt32(out, patches.length);
@@ -182,7 +189,8 @@
     try (InflaterInputStream in = new InflaterInputStream(buf)) {
       oldId = readCanBeNull(in);
       newId = readNotNull(in);
-      againstParent = readVarInt32(in) != 0;
+      isMerge = readVarInt32(in) != 0;
+      comparisonType = ComparisonType.readFrom(in);
       insertions = readVarInt32(in);
       deletions = readVarInt32(in);
       final int cnt = readVarInt32(in);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
index 8a2403f..848b78f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
@@ -33,4 +33,7 @@
 
   IntraLineDiff getIntraLineDiff(IntraLineDiffKey key,
       IntraLineDiffArgs args);
+
+  DiffSummary getDiffSummary(Change change, PatchSet patchSet)
+      throws PatchListNotAvailableException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index abafad7..f1490f6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -15,6 +15,8 @@
 
 package com.google.gerrit.server.patch;
 
+import static com.google.gerrit.server.patch.DiffSummaryLoader.toDiffSummary;
+
 import com.google.common.cache.Cache;
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
@@ -39,6 +41,7 @@
 public class PatchListCacheImpl implements PatchListCache {
   static final String FILE_NAME = "diff";
   static final String INTRA_NAME = "diff_intraline";
+  static final String DIFF_SUMMARY = "diff_summary";
 
   public static Module module() {
     return new CacheModule() {
@@ -54,6 +57,12 @@
             .maximumWeight(10 << 20)
             .weigher(IntraLineWeigher.class);
 
+        factory(DiffSummaryLoader.Factory.class);
+        persist(DIFF_SUMMARY, DiffSummaryKey.class, DiffSummary.class)
+            .maximumWeight(10 << 20)
+            .weigher(DiffSummaryWeigher.class)
+            .diskLimit(1 << 30);
+
         bind(PatchListCacheImpl.class);
         bind(PatchListCache.class).to(PatchListCacheImpl.class);
       }
@@ -62,21 +71,27 @@
 
   private final Cache<PatchListKey, PatchList> fileCache;
   private final Cache<IntraLineDiffKey, IntraLineDiff> intraCache;
+  private final Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
   private final PatchListLoader.Factory fileLoaderFactory;
   private final IntraLineLoader.Factory intraLoaderFactory;
+  private final DiffSummaryLoader.Factory diffSummaryLoaderFactory;
   private final boolean computeIntraline;
 
   @Inject
   PatchListCacheImpl(
       @Named(FILE_NAME) Cache<PatchListKey, PatchList> fileCache,
       @Named(INTRA_NAME) Cache<IntraLineDiffKey, IntraLineDiff> intraCache,
+      @Named(DIFF_SUMMARY) Cache<DiffSummaryKey, DiffSummary> diffSummaryCache,
       PatchListLoader.Factory fileLoaderFactory,
       IntraLineLoader.Factory intraLoaderFactory,
+      DiffSummaryLoader.Factory diffSummaryLoaderFactory,
       @GerritServerConfig Config cfg) {
     this.fileCache = fileCache;
     this.intraCache = intraCache;
+    this.diffSummaryCache = diffSummaryCache;
     this.fileLoaderFactory = fileLoaderFactory;
     this.intraLoaderFactory = intraLoaderFactory;
+    this.diffSummaryLoaderFactory = diffSummaryLoaderFactory;
 
     this.computeIntraline =
         cfg.getBoolean("cache", INTRA_NAME, "enabled",
@@ -87,7 +102,11 @@
   public PatchList get(PatchListKey key, Project.NameKey project)
       throws PatchListNotAvailableException {
     try {
-      return fileCache.get(key, fileLoaderFactory.create(key, project));
+      PatchList pl = fileCache.get(key, fileLoaderFactory.create(key, project));
+      diffSummaryCache.put(
+          DiffSummaryKey.fromPatchListKey(key),
+          toDiffSummary(pl));
+      return pl;
     } catch (ExecutionException e) {
       PatchListLoader.log.warn("Error computing " + key, e);
       throw new PatchListNotAvailableException(e);
@@ -140,4 +159,33 @@
     }
     return new IntraLineDiff(IntraLineDiff.Status.DISABLED);
   }
+
+  @Override
+  public DiffSummary getDiffSummary(Change change, PatchSet patchSet)
+      throws PatchListNotAvailableException {
+    Project.NameKey project = change.getProject();
+    ObjectId b = ObjectId.fromString(patchSet.getRevision().get());
+    Whitespace ws = Whitespace.IGNORE_NONE;
+    return getDiffSummary(
+        DiffSummaryKey.fromPatchListKey(
+            PatchListKey.againstDefaultBase(b, ws)),
+        project);
+  }
+
+  private DiffSummary getDiffSummary(DiffSummaryKey key,
+      Project.NameKey project) throws PatchListNotAvailableException {
+    try {
+      return diffSummaryCache.get(key,
+          diffSummaryLoaderFactory.create(key, project));
+    } catch (ExecutionException e) {
+      PatchListLoader.log.warn("Error computing " + key, e);
+      throw new PatchListNotAvailableException(e);
+    } catch (UncheckedExecutionException e) {
+      if (e.getCause() instanceof LargeObjectException) {
+        PatchListLoader.log.warn("Error computing " + key, e);
+        throw new PatchListNotAvailableException(e);
+      }
+      throw e;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
index 43e3dce..6bb32a2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
@@ -35,7 +35,7 @@
 import java.util.Objects;
 
 public class PatchListKey implements Serializable {
-  public static final long serialVersionUID = 22L;
+  public static final long serialVersionUID = 24L;
 
   public static final BiMap<Whitespace, Character> WHITESPACE_TYPES = ImmutableBiMap.of(
       Whitespace.IGNORE_NONE, 'N',
@@ -92,6 +92,15 @@
     whitespace = ws;
   }
 
+  /** For use only by DiffSummaryKey. */
+  PatchListKey(ObjectId oldId, Integer parentNum, ObjectId newId,
+      Whitespace whitespace) {
+    this.oldId = oldId;
+    this.parentNum = parentNum;
+    this.newId = newId;
+    this.whitespace = whitespace;
+  }
+
   /** Old side commit, or null to assume ancestor or combined merge. */
   @Nullable
   public ObjectId getOldId() {
@@ -138,6 +147,10 @@
     n.append("..");
     n.append(newId.name());
     n.append(" ");
+    if (parentNum != null) {
+      n.append(parentNum);
+      n.append(" ");
+    }
     n.append(whitespace.name());
     n.append("]");
     return n.toString();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
index 2fa43bb..e1829bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -17,11 +17,10 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
-import com.google.common.base.Function;
 import com.google.common.base.Throwables;
-import com.google.common.collect.FluentIterable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
@@ -70,6 +69,7 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.stream.Stream;
 
 public class PatchListLoader implements Callable<PatchList> {
   static final Logger log = LoggerFactory.getLogger(PatchListLoader.class);
@@ -155,14 +155,19 @@
 
       if (a == null) {
         // TODO(sop) Remove this case.
-        // This is a merge commit, compared to its ancestor.
+        // This is an octopus merge commit which should be compared against the
+        // auto-merge. However since we don't support computing the auto-merge
+        // for octopus merge commits, we fall back to diffing against the first
+        // parent, even though this wasn't what was requested.
         //
-        PatchListEntry[] entries = new PatchListEntry[1];
+        ComparisonType comparisonType = ComparisonType.againstParent(1);
+        PatchListEntry[] entries = new PatchListEntry[2];
         entries[0] = newCommitMessage(cmp, reader, null, b);
-        return new PatchList(a, b, true, entries);
+        entries[1] = newMergeList(cmp, reader, null, b, comparisonType);
+        return new PatchList(a, b, true, comparisonType, entries);
       }
 
-      boolean againstParent = isAgainstParent(a, b);
+      ComparisonType comparisonType = getComparisonType(a, b);
 
       RevCommit aCommit = a instanceof RevCommit ? (RevCommit) a : null;
       RevTree aTree = rw.parseTree(a);
@@ -179,22 +184,23 @@
             key.getNewId(), key.getWhitespace());
         PatchListKey oldKey = PatchListKey.againstDefaultBase(
             key.getOldId(), key.getWhitespace());
-        paths = FluentIterable
-            .from(patchListCache.get(newKey, project).getPatches())
-            .append(patchListCache.get(oldKey, project).getPatches())
-            .transform(new Function<PatchListEntry, String>() {
-              @Override
-              public String apply(PatchListEntry entry) {
-                return entry.getNewName();
-              }
-            })
-            .toSet();
+        paths = Stream.concat(
+                patchListCache.get(newKey, project).getPatches().stream(),
+                patchListCache.get(oldKey, project).getPatches().stream())
+            .map(PatchListEntry::getNewName)
+            .collect(toSet());
       }
 
       int cnt = diffEntries.size();
       List<PatchListEntry> entries = new ArrayList<>();
       entries.add(newCommitMessage(cmp, reader,
-          againstParent ? null : aCommit, b));
+          comparisonType.isAgainstParentOrAutoMerge() ? null : aCommit, b));
+      boolean isMerge = b.getParentCount() > 1;
+      if (isMerge) {
+        entries.add(newMergeList(cmp, reader,
+            comparisonType.isAgainstParentOrAutoMerge() ? null : aCommit, b,
+            comparisonType));
+      }
       for (int i = 0; i < cnt; i++) {
         DiffEntry e = diffEntries.get(i);
         if (paths == null || paths.contains(e.getNewPath())
@@ -208,19 +214,23 @@
           entries.add(newEntry(aTree, fh, newSize, newSize - oldSize));
         }
       }
-      return new PatchList(a, b, againstParent,
+      return new PatchList(a, b, isMerge, comparisonType,
           entries.toArray(new PatchListEntry[entries.size()]));
     }
   }
 
-  private boolean isAgainstParent(RevObject a, RevCommit b) {
+  private ComparisonType getComparisonType(RevObject a, RevCommit b) {
     for (int i = 0; i < b.getParentCount(); i++) {
       if (b.getParent(i).equals(a)) {
-        return true;
+        return ComparisonType.againstParent(i + 1);
       }
     }
 
-    return false;
+    if (key.getOldId() == null && b.getParentCount() > 0) {
+      return ComparisonType.againstAutoMerge();
+    }
+
+    return ComparisonType.againstOtherPatchSet();
   }
 
   private static long getFileSize(ObjectReader reader,
@@ -269,7 +279,7 @@
     } catch (ExecutionException e) {
       // If there was an error computing the result, carry it
       // up to the caller so the cache knows this key is invalid.
-      Throwables.propagateIfInstanceOf(e.getCause(), IOException.class);
+      Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
       throw new IOException(e.getMessage(), e.getCause());
     }
   }
@@ -282,32 +292,30 @@
     return diffFormatter.toFileHeader(diffEntry);
   }
 
-  private PatchListEntry newCommitMessage(final RawTextComparator cmp,
-      final ObjectReader reader,
-      final RevCommit aCommit, final RevCommit bCommit) throws IOException {
-    StringBuilder hdr = new StringBuilder();
-
-    hdr.append("diff --git");
-    if (aCommit != null) {
-      hdr.append(" a/").append(Patch.COMMIT_MSG);
-    } else {
-      hdr.append(" ").append(FileHeader.DEV_NULL);
-    }
-    hdr.append(" b/").append(Patch.COMMIT_MSG);
-    hdr.append("\n");
-
-    if (aCommit != null) {
-      hdr.append("--- a/").append(Patch.COMMIT_MSG).append("\n");
-    } else {
-      hdr.append("--- ").append(FileHeader.DEV_NULL).append("\n");
-    }
-    hdr.append("+++ b/").append(Patch.COMMIT_MSG).append("\n");
-
-    Text aText =
-        aCommit != null ? Text.forCommit(reader, aCommit) : Text.EMPTY;
+  private PatchListEntry newCommitMessage(RawTextComparator cmp,
+      ObjectReader reader, RevCommit aCommit, RevCommit bCommit)
+          throws IOException {
+    Text aText = aCommit != null
+        ? Text.forCommit(reader, aCommit)
+        : Text.EMPTY;
     Text bText = Text.forCommit(reader, bCommit);
+    return createPatchListEntry(cmp, aCommit, aText, bText, Patch.COMMIT_MSG);
+  }
 
-    byte[] rawHdr = hdr.toString().getBytes(UTF_8);
+  private PatchListEntry newMergeList(RawTextComparator cmp,
+      ObjectReader reader, RevCommit aCommit, RevCommit bCommit,
+      ComparisonType comparisonType) throws IOException {
+    Text aText = aCommit != null
+        ? Text.forMergeList(comparisonType, reader, aCommit)
+        : Text.EMPTY;
+    Text bText =
+        Text.forMergeList(comparisonType, reader, bCommit);
+    return createPatchListEntry(cmp, aCommit, aText, bText, Patch.MERGE_LIST);
+  }
+
+  private static PatchListEntry createPatchListEntry(RawTextComparator cmp,
+      RevCommit aCommit, Text aText, Text bText, String fileName) {
+    byte[] rawHdr = getRawHeader(aCommit != null, fileName);
     byte[] aContent = aText.getContent();
     byte[] bContent = bText.getContent();
     long size = bContent.length;
@@ -319,6 +327,26 @@
     return new PatchListEntry(fh, edits, size, sizeDelta);
   }
 
+  private static byte[] getRawHeader(boolean hasA, String fileName) {
+    StringBuilder hdr = new StringBuilder();
+    hdr.append("diff --git");
+    if (hasA) {
+      hdr.append(" a/").append(fileName);
+    } else {
+      hdr.append(" ").append(FileHeader.DEV_NULL);
+    }
+    hdr.append(" b/").append(fileName);
+    hdr.append("\n");
+
+    if (hasA) {
+      hdr.append("--- a/").append(fileName).append("\n");
+    } else {
+      hdr.append("--- ").append(FileHeader.DEV_NULL).append("\n");
+    }
+    hdr.append("+++ b/").append(fileName).append("\n");
+    return hdr.toString().getBytes(UTF_8);
+  }
+
   private PatchListEntry newEntry(RevTree aTree, FileHeader fileHeader,
       long size, long sizeDelta) {
     if (aTree == null // want combined diff
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
index 2ccc9f1..fab66cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
@@ -21,6 +21,10 @@
     super(message);
   }
 
+  public PatchListNotAvailableException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
   public PatchListNotAvailableException(Throwable cause) {
     super(cause);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index e09d26f..246d7a5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.prettify.common.EditList;
 import com.google.gerrit.prettify.common.SparseFileContent;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.inject.Inject;
@@ -66,7 +66,7 @@
   private ObjectReader reader;
   private Change change;
   private DiffPreferencesInfo diffPrefs;
-  private boolean againstParent;
+  private ComparisonType comparisonType;
   private ObjectId aId;
   private ObjectId bId;
 
@@ -79,7 +79,8 @@
   private int context;
 
   @Inject
-  PatchScriptBuilder(final FileTypeRegistry ftr, final PatchListCache plc) {
+  PatchScriptBuilder(FileTypeRegistry ftr,
+      PatchListCache plc) {
     a = new Side();
     b = new Side();
     registry = ftr;
@@ -106,8 +107,8 @@
     }
   }
 
-  void setTrees(final boolean ap, final ObjectId a, final ObjectId b) {
-    againstParent = ap;
+  void setTrees(final ComparisonType ct, final ObjectId a, final ObjectId b) {
+    comparisonType = ct;
     aId = a;
     bId = b;
   }
@@ -282,8 +283,8 @@
     int lastLine;
 
     lastLine = -1;
-    for (PatchLineComment plc : comments.getCommentsA()) {
-      final int a = plc.getLine();
+    for (Comment c : comments.getCommentsA()) {
+      final int a = c.lineNbr;
       if (lastLine != a) {
         final int b = mapA2B(a - 1);
         if (0 <= b) {
@@ -294,8 +295,8 @@
     }
 
     lastLine = -1;
-    for (PatchLineComment plc : comments.getCommentsB()) {
-      final int b = plc.getLine();
+    for (Comment c : comments.getCommentsB()) {
+      int b = c.lineNbr;
       if (lastLine != b) {
         final int a = mapB2A(b - 1);
         if (0 <= a) {
@@ -435,7 +436,8 @@
       try {
         final boolean reuse;
         if (Patch.COMMIT_MSG.equals(path)) {
-          if (againstParent && (aId == within || within.equals(aId))) {
+          if (comparisonType.isAgainstParentOrAutoMerge()
+              && (aId == within || within.equals(aId))) {
             id = ObjectId.zeroId();
             src = Text.EMPTY;
             srcContent = Text.NO_BYTES;
@@ -453,7 +455,26 @@
             }
           }
           reuse = false;
-
+        } else if (Patch.MERGE_LIST.equals(path)) {
+          if (comparisonType.isAgainstParentOrAutoMerge()
+              && (aId == within || within.equals(aId))) {
+            id = ObjectId.zeroId();
+            src = Text.EMPTY;
+            srcContent = Text.NO_BYTES;
+            mode = FileMode.MISSING;
+            displayMethod = DisplayMethod.NONE;
+          } else {
+            id = within;
+            src = Text.forMergeList(comparisonType, reader, within);
+            srcContent = src.getContent();
+            if (src == Text.EMPTY) {
+              mode = FileMode.MISSING;
+              displayMethod = DisplayMethod.NONE;
+            } else {
+              mode = FileMode.REGULAR_FILE;
+            }
+          }
+          reuse = false;
         } else {
           final TreeWalk tw = find(within);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index a7d2523..42324f5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -15,9 +15,7 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.util.GitUtil.getParent;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.CommentDetail;
 import com.google.gerrit.common.data.PatchScript;
@@ -26,14 +24,14 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountInfoCacheFactory;
 import com.google.gerrit.server.edit.ChangeEdit;
@@ -60,6 +58,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.Callable;
 
 
@@ -89,7 +88,7 @@
   private final PatchListCache patchListCache;
   private final ReviewDb db;
   private final AccountInfoCacheFactory.Factory aicFactory;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   private final String fileName;
   @Nullable
@@ -119,7 +118,7 @@
       PatchListCache patchListCache,
       ReviewDb db,
       AccountInfoCacheFactory.Factory aicFactory,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
       @Assisted ChangeControl control,
       @Assisted final String fileName,
@@ -133,7 +132,7 @@
     this.db = db;
     this.control = control;
     this.aicFactory = aicFactory;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.editReader = editReader;
 
     this.fileName = fileName;
@@ -152,7 +151,7 @@
       PatchListCache patchListCache,
       ReviewDb db,
       AccountInfoCacheFactory.Factory aicFactory,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
       @Assisted ChangeControl control,
       @Assisted String fileName,
@@ -166,7 +165,7 @@
     this.db = db;
     this.control = control;
     this.aicFactory = aicFactory;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.editReader = editReader;
 
     this.fileName = fileName;
@@ -214,8 +213,6 @@
       bId = toObjectId(psEntityB);
       if (parentNum < 0) {
         aId = psEntityA != null ? toObjectId(psEntityA) : null;
-      } else {
-        aId = getParent(git, bId, parentNum);
       }
 
       try {
@@ -247,7 +244,10 @@
   }
 
   private PatchListKey keyFor(final Whitespace whitespace) {
-    return new PatchListKey(aId, bId, whitespace);
+    if (parentNum < 0) {
+      return new PatchListKey(aId, bId, whitespace);
+    }
+    return PatchListKey.againstParentNum(parentNum + 1, bId, whitespace);
   }
 
   private PatchList listFor(final PatchListKey key)
@@ -260,7 +260,7 @@
     b.setRepository(git, project);
     b.setChange(change);
     b.setDiffPrefs(diffPrefs);
-    b.setTrees(list.isAgainstParent(), list.getOldId(), list.getNewId());
+    b.setTrees(list.getComparisonType(), list.getOldId(), list.getNewId());
     return b;
   }
 
@@ -402,13 +402,14 @@
   private void loadPublished(final Map<Patch.Key, Patch> byKey,
       final AccountInfoCacheFactory aic, final String file) throws OrmException {
     ChangeNotes notes = control.getNotes();
-    for (PatchLineComment c : plcUtil.publishedByChangeFile(db, notes, changeId, file)) {
-      if (comments.include(c)) {
-        aic.want(c.getAuthor());
+    for (Comment c : commentsUtil.publishedByChangeFile(db, notes, changeId, file)) {
+      if (comments.include(change.getId(), c)) {
+        aic.want(c.author.getId());
       }
 
-      final Patch.Key pKey = c.getKey().getParentKey();
-      final Patch p = byKey.get(pKey);
+      PatchSet.Id psId = new PatchSet.Id(change.getId(), c.key.patchSetId);
+      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      Patch p = byKey.get(pKey);
       if (p != null) {
         p.setCommentCount(p.getCommentCount() + 1);
       }
@@ -418,14 +419,15 @@
   private void loadDrafts(final Map<Patch.Key, Patch> byKey,
       final AccountInfoCacheFactory aic, final Account.Id me, final String file)
       throws OrmException {
-    for (PatchLineComment c :
-        plcUtil.draftByChangeFileAuthor(db, control.getNotes(), file, me)) {
-      if (comments.include(c)) {
+    for (Comment c :
+        commentsUtil.draftByChangeFileAuthor(db, control.getNotes(), file, me)) {
+      if (comments.include(change.getId(), c)) {
         aic.want(me);
       }
 
-      final Patch.Key pKey = c.getKey().getParentKey();
-      final Patch p = byKey.get(pKey);
+      PatchSet.Id psId = new PatchSet.Id(change.getId(), c.key.patchSetId);
+      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      Patch p = byKey.get(pKey);
       if (p != null) {
         p.setDraftCount(p.getDraftCount() + 1);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java
index 7982479..a84dd92 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java
@@ -87,6 +87,36 @@
     }
   }
 
+  public static Text forMergeList(ComparisonType comparisonType,
+      ObjectReader reader, AnyObjectId commitId) throws IOException {
+    try (RevWalk rw = new RevWalk(reader)) {
+      RevCommit c = rw.parseCommit(commitId);
+      StringBuilder b = new StringBuilder();
+      switch (c.getParentCount()) {
+        case 0:
+          break;
+        case 1: {
+          break;
+        }
+        default:
+          int uniterestingParent = comparisonType.isAgainstParent()
+              ? comparisonType.getParentNum()
+              : 1;
+
+          b.append("Merge List:\n\n");
+          for (RevCommit commit : MergeListBuilder.build(rw, c,
+              uniterestingParent)) {
+            b.append("* ");
+            b.append(reader.abbreviate(commit, 8).name());
+            b.append(" ");
+            b.append(commit.getShortMessage());
+            b.append("\n");
+          }
+      }
+      return new Text(b.toString().getBytes(UTF_8));
+    }
+  }
+
   private static void appendPersonIdent(StringBuilder b, String field,
       PersonIdent person) {
     if (person != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
index 1f612a3..354ccf9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -17,13 +17,9 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.Iterables.transform;
 
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicates;
 import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
@@ -50,6 +46,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.jar.Attributes;
 import java.util.jar.JarEntry;
@@ -59,15 +56,6 @@
 public class JarScanner implements PluginContentScanner {
   private static final int SKIP_ALL = ClassReader.SKIP_CODE
       | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;
-  private static final Function<ClassData, ExtensionMetaData> CLASS_DATA_TO_EXTENSION_META_DATA =
-      new Function<ClassData, ExtensionMetaData>() {
-        @Override
-        public ExtensionMetaData apply(ClassData classData) {
-          return new ExtensionMetaData(classData.className,
-              classData.annotationValue);
-        }
-      };
-
   private final JarFile jarFile;
 
   public JarScanner(Path src) throws IOException {
@@ -128,8 +116,11 @@
       Collection<ClassData> values =
           firstNonNull(discoverdData, Collections.<ClassData> emptySet());
 
-      result.put(annotoation,
-          transform(values, CLASS_DATA_TO_EXTENSION_META_DATA));
+      result.put(
+          annotoation,
+          transform(
+              values,
+              cd -> new ExtensionMetaData(cd.className, cd.annotationValue)));
     }
 
     return result.build();
@@ -199,10 +190,10 @@
     String annotationName;
     String annotationValue;
     String[] interfaces;
-    Iterable<String> exports;
+    Collection<String> exports;
 
-    private ClassData(Iterable<String> exports) {
-      super(Opcodes.ASM4);
+    private ClassData(Collection<String> exports) {
+      super(Opcodes.ASM5);
       this.exports = exports;
     }
 
@@ -221,9 +212,12 @@
 
     @Override
     public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
+      if (!visible) {
+        return null;
+      }
       Optional<String> found =
-          Iterables.tryFind(exports, Predicates.equalTo(desc));
-      if (visible && found.isPresent()) {
+          exports.stream().filter(x -> x.equals(desc)).findAny();
+      if (found.isPresent()) {
         annotationName = desc;
         return new AbstractAnnotationVisitor() {
           @Override
@@ -271,7 +265,7 @@
   private abstract static class AbstractAnnotationVisitor extends
       AnnotationVisitor {
     AbstractAnnotationVisitor() {
-      super(Opcodes.ASM4);
+      super(Opcodes.ASM5);
     }
 
     @Override
@@ -294,10 +288,11 @@
   }
 
   @Override
-  public Optional<PluginEntry> getEntry(String resourcePath) throws IOException {
+  public Optional<PluginEntry> getEntry(String resourcePath)
+      throws IOException {
     JarEntry jarEntry = jarFile.getJarEntry(resourcePath);
     if (jarEntry == null || jarEntry.getSize() == 0) {
-      return Optional.absent();
+      return Optional.empty();
     }
 
     return Optional.of(resourceOf(jarEntry));
@@ -307,15 +302,12 @@
   public Enumeration<PluginEntry> entries() {
     return Collections.enumeration(Lists.transform(
         Collections.list(jarFile.entries()),
-        new Function<JarEntry, PluginEntry>() {
-          @Override
-          public PluginEntry apply(JarEntry jarEntry) {
-            try {
-              return resourceOf(jarEntry);
-            } catch (IOException e) {
-              throw new IllegalArgumentException("Cannot convert jar entry "
-                  + jarEntry + " to a resource", e);
-            }
+        jarEntry -> {
+          try {
+            return resourceOf(jarEntry);
+          } catch (IOException e) {
+            throw new IllegalArgumentException("Cannot convert jar entry "
+                + jarEntry + " to a resource", e);
           }
         }));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
index cf38310..e89eb7d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.server.plugins;
 
-import com.google.common.base.Function;
-import com.google.common.base.Joiner;
-import com.google.common.collect.Iterables;
+import static java.util.stream.Collectors.joining;
 
 import java.nio.file.Path;
+import java.util.stream.StreamSupport;
 
 class MultipleProvidersForPluginException extends IllegalArgumentException {
   private static final long serialVersionUID = 1L;
@@ -32,14 +31,8 @@
 
   private static String providersListToString(
       Iterable<ServerPluginProvider> providersHandlers) {
-    Iterable<String> providerNames =
-        Iterables.transform(providersHandlers,
-            new Function<ServerPluginProvider, String>() {
-              @Override
-              public String apply(ServerPluginProvider provider) {
-                return provider.getProviderPluginName();
-              }
-            });
-    return Joiner.on(", ").join(providerNames);
+    return StreamSupport.stream(providersHandlers.spliterator(), false)
+        .map(ServerPluginProvider::getProviderPluginName)
+        .collect(joining(", "));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
index 15bb92f..c333638 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.plugins;
 
-import com.google.common.base.Optional;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
@@ -23,6 +21,7 @@
 import java.util.Collections;
 import java.util.Enumeration;
 import java.util.Map;
+import java.util.Optional;
 import java.util.jar.Manifest;
 
 /**
@@ -51,9 +50,8 @@
     }
 
     @Override
-    public Optional<PluginEntry> getEntry(String resourcePath)
-        throws IOException {
-      return Optional.absent();
+    public Optional<PluginEntry> getEntry(String resourcePath) {
+      return Optional.empty();
     }
 
     @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
index 74ded73..c6077f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
@@ -11,13 +11,13 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-package com.google.gerrit.server.plugins;
 
-import com.google.common.base.Optional;
+package com.google.gerrit.server.plugins;
 
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.Map;
+import java.util.Optional;
 
 /**
  * Plugin static resource entry
@@ -38,7 +38,7 @@
       };
 
   private static final Map<Object, String> EMPTY_ATTRS = Collections.emptyMap();
-  private static final Optional<Long> NO_SIZE = Optional.absent();
+  private static final Optional<Long> NO_SIZE = Optional.empty();
 
   private final String name;
   private final long time;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
index e170510..5667003 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -17,7 +17,6 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Predicate;
 import com.google.common.base.Strings;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
@@ -720,12 +719,9 @@
 
   private static Iterable<Path> filterDisabledPlugins(
       Collection<Path> paths) {
-    return Iterables.filter(paths, new Predicate<Path>() {
-      @Override
-      public boolean apply(Path p) {
-        return !p.getFileName().toString().endsWith(".disabled");
-      }
-    });
+    return Iterables.filter(
+        paths,
+        p -> !p.getFileName().toString().endsWith(".disabled"));
   }
 
   public String getGerritPluginName(Path srcPath) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java
index f0c2b78..ce97a83 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -91,14 +90,7 @@
     if (commits == null || commits.isEmpty()) {
       return null;
     }
-
-    return Lists.transform(commits,
-        new Function<ObjectId, String>() {
-          @Override
-          public String apply(ObjectId id) {
-            return id.getName();
-          }
-        });
+    return Lists.transform(commits, ObjectId::getName);
   }
 
   public static class BanResultInfo {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
index 9086b6a..f7ebd77 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
@@ -261,15 +261,28 @@
         && isVisible(db);
   }
 
-  /** Can this user delete this draft change or any draft patch set of this change? */
-  public boolean canDeleteDraft(final ReviewDb db) throws OrmException {
-    return (isOwner() || getRefControl().canDeleteDrafts())
-        && isVisible(db);
+  /** Can this user delete this change or any patch set of this change? */
+  public boolean canDelete(ReviewDb db, Change.Status status)
+      throws OrmException {
+    if (!isVisible(db)) {
+      return false;
+    }
+
+    switch (status) {
+      case DRAFT:
+        return (isOwner() || getRefControl().canDeleteDrafts());
+      case NEW:
+      case ABANDONED:
+        return isAdmin();
+      case MERGED:
+      default:
+        return false;
+    }
   }
 
   /** Can this user rebase this change? */
   public boolean canRebase(ReviewDb db) throws OrmException {
-    return (isOwner() || getRefControl().canSubmit()
+    return (isOwner() || getRefControl().canSubmit(isOwner())
         || getRefControl().canRebase()) && !isPatchSetLocked(db);
   }
 
@@ -352,6 +365,16 @@
     return false;
   }
 
+  /** Is this user assigned to this change? */
+  public boolean isAssignee() {
+    Account.Id currentAssignee = notes.getChange().getAssignee();
+    if (currentAssignee != null && getUser().isIdentifiedUser()) {
+      Account.Id id = getUser().getAccountId();
+      return id.equals(currentAssignee);
+    }
+    return false;
+  }
+
   /** Is this user a reviewer for the change? */
   public boolean isReviewer(ReviewDb db) throws OrmException {
     return isReviewer(db, null);
@@ -367,6 +390,10 @@
     return false;
   }
 
+  public boolean isAdmin() {
+    return getUser().getCapabilities().canAdministrateServer();
+  }
+
   /** @return true if the user is allowed to remove this reviewer. */
   public boolean canRemoveReviewer(PatchSetApproval approval) {
     return canRemoveReviewer(approval.getAccountId(), approval.getValue());
@@ -414,6 +441,13 @@
     return getRefControl().canForceEditTopicName();
   }
 
+  public boolean canEditAssignee() {
+    return isOwner()
+        || getProjectControl().isOwner()
+        || getRefControl().canEditAssignee()
+        || isAssignee();
+  }
+
   /** Can this user edit the hashtag name? */
   public boolean canEditHashtags() {
     return isOwner() // owner (aka creator) of the change can edit hashtags
@@ -424,7 +458,7 @@
   }
 
   public boolean canSubmit() {
-    return getRefControl().canSubmit();
+    return getRefControl().canSubmit(isOwner());
   }
 
   public boolean canSubmitAs() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
index 446fa72..9e090c4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
@@ -116,7 +116,7 @@
       if (isSigned) {
         throw new MethodNotAllowedException(
             "Cannot create signed tag \"" + ref + "\"");
-      } else if (isAnnotated && !refControl.canPerform(Permission.PUSH_TAG)) {
+      } else if (isAnnotated && !refControl.canPerform(Permission.CREATE_TAG)) {
         throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
       } else if (!refControl.canPerform(Permission.CREATE)) {
         throw new AuthException("Cannot create tag \"" + ref + "\"");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
index 47942be..82ea155 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
@@ -14,16 +14,39 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.TypeLiteral;
 
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+import java.io.IOException;
 
 public class FileResource implements RestResource {
   public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
       new TypeLiteral<RestView<FileResource>>() {};
 
+  public static FileResource create(GitRepositoryManager repoManager,
+      ProjectControl project, ObjectId rev, String path)
+          throws ResourceNotFoundException, IOException {
+    try (Repository repo =
+            repoManager.openRepository(project.getProject().getNameKey());
+        RevWalk rw = new RevWalk(repo)) {
+      RevTree tree = rw.parseTree(rev);
+      if (TreeWalk.forPath(repo, path, tree) != null) {
+        return new FileResource(project, rev, path);
+      }
+    }
+    throw new ResourceNotFoundException(IdString.fromDecoded(path));
+  }
+
   private final ProjectControl project;
   private final ObjectId rev;
   private final String path;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java
index d0460d5..dcb8747 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java
@@ -19,19 +19,25 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.lib.ObjectId;
 
+import java.io.IOException;
+
 @Singleton
 public class FilesCollection implements
     ChildCollection<BranchResource, FileResource> {
   private final DynamicMap<RestView<FileResource>> views;
+  private final GitRepositoryManager repoManager;
 
   @Inject
-  FilesCollection(DynamicMap<RestView<FileResource>> views) {
+  FilesCollection(DynamicMap<RestView<FileResource>> views,
+      GitRepositoryManager repoManager) {
     this.views = views;
+    this.repoManager = repoManager;
   }
 
   @Override
@@ -40,11 +46,10 @@
   }
 
   @Override
-  public FileResource parse(BranchResource parent, IdString id) {
-    return new FileResource(
-        parent.getControl(),
-        ObjectId.fromString(parent.getRevision()),
-        id.get());
+  public FileResource parse(BranchResource parent, IdString id)
+      throws ResourceNotFoundException, IOException {
+    return FileResource.create(repoManager, parent.getControl(),
+        ObjectId.fromString(parent.getRevision()), id.get());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java
index 8e0aab8..0f44a48 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java
@@ -19,17 +19,24 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
+
 @Singleton
 public class FilesInCommitCollection implements
     ChildCollection<CommitResource, FileResource> {
   private final DynamicMap<RestView<FileResource>> views;
+  private final GitRepositoryManager repoManager;
 
   @Inject
-  FilesInCommitCollection(DynamicMap<RestView<FileResource>> views) {
+  FilesInCommitCollection(DynamicMap<RestView<FileResource>> views,
+      GitRepositoryManager repoManager) {
     this.views = views;
+    this.repoManager = repoManager;
   }
 
   @Override
@@ -39,8 +46,13 @@
 
   @Override
   public FileResource parse(CommitResource parent, IdString id)
-      throws ResourceNotFoundException {
-    return new FileResource(parent.getProject(), parent.getCommit(), id.get());
+      throws ResourceNotFoundException, IOException {
+    if (Patch.isMagic(id.get())) {
+      return new FileResource(parent.getProject(), parent.getCommit(),
+          id.get());
+    }
+    return FileResource.create(repoManager, parent.getProject(),
+        parent.getCommit(), id.get());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java
index b957ba1..8718a9b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -89,8 +88,8 @@
             limit > 0 ? r.getReverseEntries(limit) : r.getReverseEntries();
       } else {
         entries = limit > 0
-            ? new ArrayList<ReflogEntry>(limit)
-            : new ArrayList<ReflogEntry>();
+            ? new ArrayList<>(limit)
+            : new ArrayList<>();
         for (ReflogEntry e : r.getReverseEntries()) {
           Timestamp timestamp = new Timestamp(e.getWho().getWhen().getTime());
           if ((from == null || from.before(timestamp)) &&
@@ -102,12 +101,7 @@
           }
         }
       }
-      return Lists.transform(entries, new Function<ReflogEntry, ReflogEntryInfo>() {
-        @Override
-        public ReflogEntryInfo apply(ReflogEntry e) {
-          return new ReflogEntryInfo(e);
-        }
-      });
+      return Lists.transform(entries, ReflogEntryInfo::new);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
index bf17a37..1ea0c62 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
@@ -16,7 +16,6 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Predicate;
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
@@ -445,13 +444,8 @@
     } else if (matchSubstring != null) {
       checkMatchOptions(matchPrefix == null && matchRegex == null);
       return Iterables.filter(projectCache.all(),
-          new Predicate<Project.NameKey>() {
-            @Override
-            public boolean apply(Project.NameKey in) {
-              return in.get().toLowerCase(Locale.US)
-                  .contains(matchSubstring.toLowerCase(Locale.US));
-            }
-          });
+          p -> p.get().toLowerCase(Locale.US)
+              .contains(matchSubstring.toLowerCase(Locale.US)));
     } else if (matchRegex != null) {
       checkMatchOptions(matchPrefix == null && matchSubstring == null);
       RegexListSearcher<Project.NameKey> searcher;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
index a862ac2..ce1bab4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
@@ -18,8 +18,10 @@
 import static com.google.gerrit.server.project.RefPattern.isRE;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Permission;
@@ -116,6 +118,8 @@
       HashMap<String, List<PermissionRule>> permissions = new HashMap<>();
       HashMap<String, List<PermissionRule>> overridden = new HashMap<>();
       Map<PermissionRule, ProjectRef> ruleProps = Maps.newIdentityHashMap();
+      Multimap<Project.NameKey, String> exclusivePermissionsByProject =
+          ArrayListMultimap.create();
       for (AccessSection section : sections) {
         Project.NameKey project = sectionToProject.get(section);
         for (Permission permission : section.getPermissions()) {
@@ -126,7 +130,8 @@
             SeenRule s = SeenRule.create(section, permission, rule);
             boolean addRule;
             if (rule.isBlock()) {
-              addRule = true;
+              addRule = !exclusivePermissionsByProject.containsEntry(project,
+                  permission.getName());
             } else {
               addRule = seen.add(s) && !rule.isDeny() && !exclusivePermissionExists;
             }
@@ -150,6 +155,7 @@
           }
 
           if (permission.getExclusiveGroup()) {
+            exclusivePermissionsByProject.put(project, permission.getName());
             exclusiveGroupPermissions.add(permission.getName());
           }
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 8a08052..41b8721 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.Predicate;
+import static java.util.stream.Collectors.toSet;
+
 import com.google.common.base.Throwables;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -43,9 +42,9 @@
 
 import java.io.IOException;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.Iterator;
 import java.util.NoSuchElementException;
+import java.util.Objects;
 import java.util.Set;
 import java.util.SortedSet;
 import java.util.concurrent.ExecutionException;
@@ -61,14 +60,6 @@
   private static final String CACHE_NAME = "projects";
   private static final String CACHE_LIST = "project_list";
 
-  private static final Predicate<AccountGroup.UUID> NON_NULL_UUID =
-      new Predicate<AccountGroup.UUID>() {
-        @Override
-        public boolean apply(AccountGroup.UUID uuid) {
-          return uuid != null && uuid.get() != null;
-        }
-      };
-
   public static Module module() {
     return new CacheModule() {
       @Override
@@ -159,7 +150,7 @@
     } catch (ExecutionException e) {
       if (!(e.getCause() instanceof RepositoryNotFoundException)) {
         log.warn(String.format("Cannot read project %s", projectName.get()), e);
-        Throwables.propagateIfInstanceOf(e.getCause(), IOException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
         throw new IOException(e);
       }
       return null;
@@ -216,23 +207,19 @@
       return list.get(ListKey.ALL);
     } catch (ExecutionException e) {
       log.warn("Cannot list available projects", e);
-      return ImmutableSortedSet.of();
+      return Collections.emptySortedSet();
     }
   }
 
   @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-    Set<AccountGroup.UUID> groups = new HashSet<>();
-    for (Project.NameKey n : all()) {
-      ProjectState p = byName.getIfPresent(n.get());
-      if (p != null) {
-        groups.addAll(FluentIterable
-            .from(p.getConfig().getAllGroupUUIDs())
-            .filter(NON_NULL_UUID)
-            .toSet());
-      }
-    }
-    return groups;
+    return all().stream().map(n -> byName.getIfPresent(n.get()))
+        .filter(Objects::nonNull)
+        .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
+        // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
+        // against them just in case there is a bug or corner case.
+        .filter(id -> id != null && id.get() != null)
+        .collect(toSet());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
index 22e5d69..d9cc59c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
@@ -307,8 +307,9 @@
 
   /** Is this user a project owner? Ownership does not imply {@link #isVisible()} */
   public boolean isOwner() {
-    return isDeclaredOwner()
-      || user.getCapabilities().canAdministrateServer();
+    return (isDeclaredOwner()
+        && !controlForRef("refs/*").isBlocked(Permission.OWNER))
+        || user.getCapabilities().canAdministrateServer();
   }
 
   private boolean isDeclaredOwner() {
@@ -327,8 +328,8 @@
 
   /** @return true if the user can upload to at least one reference */
   public Capable canPushToAtLeastOneRef() {
-    if (! canPerformOnAnyRef(Permission.PUSH) &&
-        ! canPerformOnAnyRef(Permission.PUSH_TAG)) {
+    if (!canPerformOnAnyRef(Permission.PUSH) &&
+        !canPerformOnAnyRef(Permission.CREATE_TAG)) {
       String pName = state.getProject().getName();
       return new Capable("Upload denied for project '" + pName + "'");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
index 68d236e..f4ef129 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
@@ -17,7 +17,7 @@
 import static com.google.gerrit.common.data.PermissionRule.Action.ALLOW;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Function;
+import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
@@ -72,6 +72,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
 
 /** Cached information on a project. */
 public class ProjectState {
@@ -365,8 +366,8 @@
    *         from the immediate parent of this project and progresses up the
    *         hierarchy to All-Projects.
    */
-  public Iterable<ProjectState> parents() {
-    return Iterables.skip(tree(), 1);
+  public FluentIterable<ProjectState> parents() {
+    return FluentIterable.from(tree()).skip(1);
   }
 
   public boolean isAllProjects() {
@@ -378,75 +379,35 @@
   }
 
   public boolean isUseContributorAgreements() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getUseContributorAgreements();
-      }
-    });
+    return getInheritableBoolean(Project::getUseContributorAgreements);
   }
 
   public boolean isUseContentMerge() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getUseContentMerge();
-      }
-    });
+    return getInheritableBoolean(Project::getUseContentMerge);
   }
 
   public boolean isUseSignedOffBy() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getUseSignedOffBy();
-      }
-    });
+    return getInheritableBoolean(Project::getUseSignedOffBy);
   }
 
   public boolean isRequireChangeID() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getRequireChangeID();
-      }
-    });
+    return getInheritableBoolean(Project::getRequireChangeID);
   }
 
   public boolean isCreateNewChangeForAllNotInTarget() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getCreateNewChangeForAllNotInTarget();
-      }
-    });
+    return getInheritableBoolean(Project::getCreateNewChangeForAllNotInTarget);
   }
 
   public boolean isEnableSignedPush() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getEnableSignedPush();
-      }
-    });
+    return getInheritableBoolean(Project::getEnableSignedPush);
   }
 
   public boolean isRequireSignedPush() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getRequireSignedPush();
-      }
-    });
+    return getInheritableBoolean(Project::getRequireSignedPush);
   }
 
   public boolean isRejectImplicitMerges() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getRejectImplicitMerges();
-      }
-    });
+    return getInheritableBoolean(Project::getRejectImplicitMerges);
   }
 
   public LabelTypes getLabelTypes() {
@@ -551,7 +512,8 @@
     return Files.exists(p) ? new String(Files.readAllBytes(p), UTF_8) : null;
   }
 
-  private boolean getInheritableBoolean(Function<Project, InheritableBoolean> func) {
+  private boolean getInheritableBoolean(
+      Function<Project, InheritableBoolean> func) {
     for (ProjectState s : tree()) {
       switch (func.apply(s.getProject())) {
         case TRUE:
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
index e06fb86..52bbdf3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -23,7 +24,7 @@
 public class PutBranch implements RestModifyView<BranchResource, BranchInput> {
 
   @Override
-  public Object apply(BranchResource rsrc, BranchInput input)
+  public BranchInfo apply(BranchResource rsrc, BranchInput input)
       throws ResourceConflictException {
     throw new ResourceConflictException("Branch \"" + rsrc.getBranchInfo().ref
         + "\" already exists");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
index a87882e..1be4b0e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -21,7 +22,7 @@
 public class PutTag implements RestModifyView<TagResource, TagInput> {
 
   @Override
-  public Object apply(TagResource resource, TagInput input)
+  public TagInfo apply(TagResource resource, TagInput input)
       throws ResourceConflictException {
     throw new ResourceConflictException("Tag \"" + resource.getTagInfo().ref
         + "\" already exists");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
index ad41522..3314309 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
@@ -171,7 +171,7 @@
   }
 
   /** @return true if this user can submit patch sets to this ref */
-  public boolean canSubmit() {
+  public boolean canSubmit(boolean isChangeOwner) {
     if (RefNames.REFS_CONFIG.equals(refName)) {
       // Always allow project owners to submit configuration changes.
       // Submitting configuration changes modifies the access control
@@ -180,7 +180,7 @@
       // granting of powers beyond submitting to the configuration.
       return projectControl.isOwner();
     }
-    return canPerform(Permission.SUBMIT)
+    return canPerform(Permission.SUBMIT, isChangeOwner)
         && canWrite();
   }
 
@@ -213,7 +213,27 @@
 
   /** @return true if the user can rewind (force push) the reference. */
   public boolean canForceUpdate() {
-    return (canPushWithForce() || canDelete()) && canWrite();
+    if (!canWrite()) {
+      return false;
+    }
+
+    if (canPushWithForce()) {
+      return true;
+    }
+
+    switch (getUser().getAccessPath()) {
+      case GIT:
+        return false;
+
+      case JSON_RPC:
+      case REST_API:
+      case SSH_COMMAND:
+      case UNKNOWN:
+      case WEB_BROWSER:
+      default:
+        return getUser().getCapabilities().canAdministrateServer()
+            || (isOwner() && !isForceBlocked(Permission.PUSH));
+    }
   }
 
   public boolean canWrite() {
@@ -251,43 +271,13 @@
     if (!canWrite()) {
       return false;
     }
-    boolean owner;
-    boolean admin;
-    switch (getUser().getAccessPath()) {
-      case REST_API:
-      case JSON_RPC:
-      case UNKNOWN:
-        owner = isOwner();
-        admin = getUser().getCapabilities().canAdministrateServer();
-        break;
-
-      case GIT:
-      case SSH_COMMAND:
-      case WEB_BROWSER:
-      default:
-        owner = false;
-        admin = false;
-    }
 
     if (object instanceof RevCommit) {
-      if (admin || (owner && !isBlocked(Permission.CREATE))) {
-        // Admin or project owner; bypass visibility check.
-        return true;
-      } else if (!canPerform(Permission.CREATE)) {
+      if (!canPerform(Permission.CREATE)) {
         // No create permissions.
         return false;
-      } else if (canUpdate()) {
-        // If the user has push permissions, they can create the ref regardless
-        // of whether they are pushing any new objects along with the create.
-        return true;
-      } else if (isMergedIntoBranchOrTag(db, repo, (RevCommit) object)) {
-        // If the user has no push permissions, check whether the object is
-        // merged into a branch or tag readable by this user. If so, they are
-        // not effectively "pushing" more objects, so they can create the ref
-        // even if they don't have push permission.
-        return true;
       }
-      return false;
+      return canCreateCommit(db, repo, (RevCommit) object);
     } else if (object instanceof RevTag) {
       final RevTag tag = (RevTag) object;
       try (RevWalk rw = new RevWalk(repo)) {
@@ -307,7 +297,18 @@
         } else {
           valid = false;
         }
-        if (!valid && !owner && !canForgeCommitter()) {
+        if (!valid && !canForgeCommitter()) {
+          return false;
+        }
+      }
+
+      RevObject tagObject = tag.getObject();
+      if (tagObject instanceof RevCommit) {
+        if (!canCreateCommit(db, repo, (RevCommit) tagObject)) {
+          return false;
+        }
+      } else {
+        if (!canCreate(db, repo, tagObject)) {
           return false;
         }
       }
@@ -316,14 +317,30 @@
       // than if it doesn't have a PGP signature.
       //
       if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
-        return owner || canPerform(Permission.PUSH_SIGNED_TAG);
+        return canPerform(Permission.CREATE_SIGNED_TAG);
       }
-      return owner || canPerform(Permission.PUSH_TAG);
+      return canPerform(Permission.CREATE_TAG);
     } else {
       return false;
     }
   }
 
+  private boolean canCreateCommit(ReviewDb db, Repository repo,
+      RevCommit commit) {
+    if (canUpdate()) {
+      // If the user has push permissions, they can create the ref regardless
+      // of whether they are pushing any new objects along with the create.
+      return true;
+    } else if (isMergedIntoBranchOrTag(db, repo, commit)) {
+      // If the user has no push permissions, check whether the object is
+      // merged into a branch or tag readable by this user. If so, they are
+      // not effectively "pushing" more objects, so they can create the ref
+      // even if they don't have push permission.
+      return true;
+    }
+    return false;
+  }
+
   private boolean isMergedIntoBranchOrTag(ReviewDb db, Repository repo,
       RevCommit commit) {
     try (RevWalk rw = new RevWalk(repo)) {
@@ -359,7 +376,7 @@
 
     switch (getUser().getAccessPath()) {
       case GIT:
-        return canPushWithForce();
+        return canPushWithForce() || canPerform(Permission.DELETE);
 
       case JSON_RPC:
       case REST_API:
@@ -369,7 +386,8 @@
       default:
         return getUser().getCapabilities().canAdministrateServer()
             || (isOwner() && !isForceBlocked(Permission.PUSH))
-            || canPushWithForce();
+            || canPushWithForce()
+            || canPerform(Permission.DELETE);
     }
   }
 
@@ -429,6 +447,10 @@
     return canPerform(Permission.EDIT_HASHTAGS);
   }
 
+  public boolean canEditAssignee() {
+    return canPerform(Permission.EDIT_ASSIGNEE);
+  }
+
   /** @return true if this user can force edit topic names. */
   public boolean canForceEditTopicName() {
     return canForcePerform(Permission.EDIT_TOPIC_NAME);
@@ -531,16 +553,21 @@
 
   /** True if the user has this permission. Works only for non labels. */
   boolean canPerform(String permissionName) {
-    return doCanPerform(permissionName, false);
+    return canPerform(permissionName, false);
+  }
+
+  boolean canPerform(String permissionName, boolean isChangeOwner) {
+    return doCanPerform(permissionName, isChangeOwner, false);
   }
 
   /** True if the user is blocked from using this permission. */
   public boolean isBlocked(String permissionName) {
-    return !doCanPerform(permissionName, true);
+    return !doCanPerform(permissionName, false, true);
   }
 
-  private boolean doCanPerform(String permissionName, boolean blockOnly) {
-    List<PermissionRule> access = access(permissionName);
+  private boolean doCanPerform(String permissionName, boolean isChangeOwner,
+      boolean blockOnly) {
+    List<PermissionRule> access = access(permissionName, isChangeOwner);
     List<PermissionRule> overridden = relevant.getOverridden(permissionName);
     Set<ProjectRef> allows = new HashSet<>();
     Set<ProjectRef> blocks = new HashSet<>();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
index ed50a54..8c850fb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
@@ -49,7 +49,7 @@
       try {
         return exampleCache.get(refPattern);
       } catch (ExecutionException e) {
-        Throwables.propagateIfPossible(e.getCause());
+        Throwables.throwIfUnchecked(e.getCause());
         throw new RuntimeException(e);
       }
     } else if (refPattern.endsWith("/*")) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java
index cda548a..594763e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java
@@ -20,7 +20,9 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.project.DashboardsCollection.DashboardInfo;
 import com.google.gerrit.server.project.SetDashboard.Input;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -44,7 +46,7 @@
   }
 
   @Override
-  public Object apply(DashboardResource resource, Input input)
+  public Response<DashboardInfo> apply(DashboardResource resource, Input input)
       throws AuthException, BadRequestException, ResourceConflictException,
       MethodNotAllowedException, ResourceNotFoundException, IOException {
     if (resource.isProjectDefault()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
index 01aacfb..cc215d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Predicate;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -124,13 +123,10 @@
             + " not found");
       }
 
-      if (Iterables.tryFind(parent.tree(), new Predicate<ProjectState>() {
-        @Override
-        public boolean apply(ProjectState input) {
-          return input.getProject().getNameKey()
-              .equals(ctl.getProject().getNameKey());
-        }
-      }).isPresent()) {
+      if (Iterables.tryFind(parent.tree(), p -> {
+            return p.getProject().getNameKey()
+                .equals(ctl.getProject().getNameKey());
+          }).isPresent()) {
         throw new ResourceConflictException("cycle exists between "
             + ctl.getProject().getName() + " and "
             + parent.getProject().getName());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 5d0f4f1..48cd7ee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -91,12 +91,9 @@
   private final ChangeData cd;
   private final ChangeControl control;
 
+  private SubmitRuleOptions.Builder optsBuilder = SubmitRuleOptions.defaults();
+  private SubmitRuleOptions opts;
   private PatchSet patchSet;
-  private boolean fastEvalLabels;
-  private boolean allowDraft;
-  private boolean allowClosed;
-  private boolean skipFilters;
-  private String rule;
   private boolean logErrors = true;
   private long reductionsConsumed;
 
@@ -108,6 +105,29 @@
   }
 
   /**
+   * @return immutable snapshot of options configured so far. If neither {@link
+   *     #getSubmitRule()} nor {@link #getSubmitType()} have been called yet,
+   *     state within this instance is still mutable, so may change before
+   *     evaluation. The instance's options are frozen at evaluation time.
+   */
+  public SubmitRuleOptions getOptions() {
+    if (opts != null) {
+      return opts;
+    }
+    return optsBuilder.build();
+  }
+
+  public SubmitRuleEvaluator setOptions(SubmitRuleOptions opts) {
+    checkNotStarted();
+    if (opts != null) {
+      optsBuilder = opts.toBuilder();
+    } else {
+      optsBuilder = SubmitRuleOptions.defaults();
+    }
+    return this;
+  }
+
+  /**
    * @param ps patch set of the change to evaluate. If not set, the current
    * patch set will be loaded from {@link #evaluate()} or {@link
    * #getSubmitType}.
@@ -121,12 +141,14 @@
   }
 
   /**
-   * @param fast if true, infer label information from rules rather than reading
-   *     from project config.
+   * @param fast if true assume reviewers are permitted to use label values
+   *        currently stored on the change. Fast mode bypasses some reviewer
+   *        permission checks.
    * @return this
    */
   public SubmitRuleEvaluator setFastEvalLabels(boolean fast) {
-    fastEvalLabels = fast;
+    checkNotStarted();
+    optsBuilder.fastEvalLabels(fast);
     return this;
   }
 
@@ -135,7 +157,8 @@
    * @return this
    */
   public SubmitRuleEvaluator setAllowClosed(boolean allow) {
-    allowClosed = allow;
+    checkNotStarted();
+    optsBuilder.allowClosed(allow);
     return this;
   }
 
@@ -144,7 +167,8 @@
    * @return this
    */
   public SubmitRuleEvaluator setAllowDraft(boolean allow) {
-    allowDraft = allow;
+    checkNotStarted();
+    optsBuilder.allowDraft(allow);
     return this;
   }
 
@@ -153,7 +177,8 @@
    * @return this
    */
   public SubmitRuleEvaluator setSkipSubmitFilters(boolean skip) {
-    skipFilters = skip;
+    checkNotStarted();
+    optsBuilder.skipFilters(skip);
     return this;
   }
 
@@ -162,7 +187,8 @@
    * @return this
    */
   public SubmitRuleEvaluator setRule(@Nullable String rule) {
-    this.rule = rule;
+    checkNotStarted();
+    optsBuilder.rule(rule);
     return this;
   }
 
@@ -187,23 +213,21 @@
    *     rules, including any errors.
    */
   public List<SubmitRecord> evaluate() {
+    initOptions();
     Change c = control.getChange();
-    if (!allowClosed && c.getStatus().isClosed()) {
+    if (!opts.allowClosed() && c.getStatus().isClosed()) {
       SubmitRecord rec = new SubmitRecord();
       rec.status = SubmitRecord.Status.CLOSED;
       return Collections.singletonList(rec);
     }
-    if (!allowDraft) {
-      if (c.getStatus() == Change.Status.DRAFT) {
-        return cannotSubmitDraft();
-      }
+    if (!opts.allowDraft()) {
       try {
         initPatchSet();
       } catch (OrmException e) {
         return ruleError("Error looking up patch set "
-            + control.getChange().currentPatchSetId());
+            + control.getChange().currentPatchSetId(), e);
       }
-      if (patchSet.isDraft()) {
+      if (c.getStatus() == Change.Status.DRAFT || patchSet.isDraft()) {
         return cannotSubmitDraft();
       }
     }
@@ -235,13 +259,15 @@
       if (!control.isDraftVisible(cd.db(), cd)) {
         return createRuleError("Patch set " + patchSet.getId() + " not found");
       }
-      initPatchSet();
       if (patchSet.isDraft()) {
         return createRuleError("Cannot submit draft patch sets");
       }
       return createRuleError("Cannot submit draft changes");
     } catch (OrmException err) {
-      String msg = "Cannot check visibility of patch set " + patchSet.getId();
+      PatchSet.Id psId = patchSet != null
+          ? patchSet.getId()
+          : control.getChange().currentPatchSetId();
+      String msg = "Cannot check visibility of patch set " + psId;
       log.error(msg, err);
       return createRuleError(msg);
     }
@@ -368,11 +394,12 @@
    * @return record from the evaluated rules.
    */
   public SubmitTypeRecord getSubmitType() {
+    initOptions();
     try {
       initPatchSet();
     } catch (OrmException e) {
       return typeError("Error looking up patch set "
-          + control.getChange().currentPatchSetId());
+          + control.getChange().currentPatchSetId(), e);
     }
 
     try {
@@ -452,7 +479,7 @@
     PrologEnvironment env = getPrologEnvironment(user);
     try {
       Term sr = env.once("gerrit", userRuleLocatorName, new VariableTerm());
-      if (fastEvalLabels) {
+      if (opts.fastEvalLabels()) {
         env.once("gerrit", "assume_range_from_label");
       }
 
@@ -475,7 +502,7 @@
       }
 
       Term resultsTerm = toListTerm(results);
-      if (!skipFilters) {
+      if (!opts.skipFilters()) {
         resultsTerm = runSubmitFilters(
             resultsTerm, env, filterRuleLocatorName, filterRuleWrapperName);
       }
@@ -502,18 +529,19 @@
     ProjectState projectState = control.getProjectControl().getProjectState();
     PrologEnvironment env;
     try {
-      if (rule == null) {
+      if (opts.rule() == null) {
         env = projectState.newPrologEnvironment();
       } else {
-        env = projectState.newPrologEnvironment("stdin", new StringReader(rule));
+        env = projectState.newPrologEnvironment(
+            "stdin", new StringReader(opts.rule()));
       }
     } catch (CompileException err) {
       String msg;
-      if (rule == null && control.getProjectControl().isOwner()) {
+      if (opts.rule() == null && control.getProjectControl().isOwner()) {
         msg = String.format(
             "Cannot load rules.pl for %s: %s",
             getProjectName(), err.getMessage());
-      } else if (rule != null) {
+      } else if (opts.rule() != null) {
         msg = err.getMessage();
       } else {
         msg = String.format("Cannot load rules.pl for %s", getProjectName());
@@ -547,7 +575,7 @@
       Term filterRule =
           parentEnv.once("gerrit", filterRuleLocatorName, new VariableTerm());
       try {
-        if (fastEvalLabels) {
+        if (opts.fastEvalLabels()) {
           env.once("gerrit", "assume_range_from_label");
         }
 
@@ -607,6 +635,17 @@
     return submitRule != null ? submitRule.toString() : "<unknown rule>";
   }
 
+  private void checkNotStarted() {
+    checkState(opts == null, "cannot set options after starting evaluation");
+  }
+
+  private void initOptions() {
+    if (opts == null) {
+      opts = optsBuilder.build();
+      optsBuilder = null;
+    }
+  }
+
   private void initPatchSet() throws OrmException {
     if (patchSet == null) {
       patchSet = cd.currentPatchSet();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java
new file mode 100644
index 0000000..97155ac
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+
+/**
+ * Stable identifier for options passed to a particular submit rule evaluator.
+ * <p>
+ * Used to test whether it is ok to reuse a cached list of submit records. Does
+ * not include a change or patch set ID; callers are responsible for checking
+ * those on their own.
+ */
+@AutoValue
+public abstract class SubmitRuleOptions {
+  public static Builder builder() {
+    return new AutoValue_SubmitRuleOptions.Builder();
+  }
+
+  public static Builder defaults() {
+    return builder()
+        .fastEvalLabels(false)
+        .allowDraft(false)
+        .allowClosed(false)
+        .skipFilters(false)
+        .rule(null);
+  }
+
+  public abstract boolean fastEvalLabels();
+  public abstract boolean allowDraft();
+  public abstract boolean allowClosed();
+  public abstract boolean skipFilters();
+  @Nullable public abstract String rule();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract SubmitRuleOptions.Builder fastEvalLabels(boolean fastEvalLabels);
+    public abstract SubmitRuleOptions.Builder allowDraft(boolean allowDraft);
+    public abstract SubmitRuleOptions.Builder allowClosed(boolean allowClosed);
+    public abstract SubmitRuleOptions.Builder skipFilters(boolean skipFilters);
+    public abstract SubmitRuleOptions.Builder rule(@Nullable String rule);
+
+    public abstract SubmitRuleOptions build();
+  }
+
+  public Builder toBuilder() {
+    return builder()
+        .fastEvalLabels(fastEvalLabels())
+        .allowDraft(allowDraft())
+        .allowClosed(allowClosed())
+        .skipFilters(skipFilters())
+        .rule(rule());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
index 168be5d..4acd2ba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Function;
 import com.google.common.base.Throwables;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
@@ -84,7 +83,9 @@
     try {
       return readImpl();
     } catch (OrmRuntimeException err) {
-      Throwables.propagateIfInstanceOf(err.getCause(), OrmException.class);
+      if (err.getCause() != null) {
+        Throwables.throwIfInstanceOf(err.getCause(), OrmException.class);
+      }
       throw new OrmException(err);
     }
   }
@@ -157,12 +158,7 @@
 
   private Iterable<T> buffer(ResultSet<T> scanner) {
     return FluentIterable.from(Iterables.partition(scanner, 50))
-        .transformAndConcat(new Function<List<T>, List<T>>() {
-          @Override
-          public List<T> apply(List<T> buffer) {
-            return transformBuffer(buffer);
-          }
-        });
+        .transformAndConcat(this::transformBuffer);
   }
 
   protected List<T> transformBuffer(List<T> buffer) throws OrmRuntimeException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java
index 36e5792..e98211e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.query;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
 import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.index.IndexConfig;
@@ -74,6 +75,25 @@
     }
   }
 
+  /**
+   * Run multiple queries in parallel.
+   * <p>
+   * If a limit was specified using {@link #setLimit(int)}, that limit is
+   * applied to each query independently.
+   *
+   * @param queries list of queries.
+   * @return results of the queries, one list of results per input query, in the
+   *     same order as the input.
+   */
+  public List<List<T>> query(List<Predicate<T>> queries) throws OrmException {
+    try {
+      return Lists.transform(
+          queryProcessor.query(queries), QueryResult::entities);
+    } catch (QueryParseException e) {
+      throw new OrmException(e);
+    }
+  }
+
   protected Schema<T> schema() {
     Index<?, T> index = indexes != null ? indexes.getSearchIndex() : null;
     return index != null ? index.getSchema() : null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
index 3a21ce4..644ed63 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
@@ -353,6 +353,9 @@
       } catch (RuntimeException | IllegalAccessException e) {
         throw error("Error in operator " + name + ":" + value, e);
       } catch (InvocationTargetException e) {
+        if (e.getCause() instanceof QueryParseException) {
+          throw (QueryParseException) e.getCause();
+        }
         throw error("Error in operator " + name + ":" + value, e.getCause());
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
index 8373d4d..a0f66db 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
@@ -122,16 +122,12 @@
     return query(ImmutableList.of(query)).get(0);
   }
 
-  /*
-   * Perform multiple queries over a list of query strings.
-   * <p>
-   * If a limit was specified using {@link #setLimit(int)} this method may
-   * return up to {@code limit + 1} results, allowing the caller to determine if
-   * there are more than {@code limit} matches and suggest to its own caller
-   * that the query could be retried with {@link #setStart(int)}.
+  /**
+   * Perform multiple queries in parallel.
    *
-   * @param queries the queries.
-   * @return results of the queries, one list per input query.
+   * @param queries list of queries.
+   * @return results of the queries, one QueryResult per input query, in the
+   *     same order as the input.
    */
   public List<QueryResult<T>> query(List<Predicate<T>> queries)
       throws OrmException, QueryParseException {
@@ -140,7 +136,9 @@
     } catch (OrmRuntimeException e) {
       throw new OrmException(e.getMessage(), e);
     } catch (OrmException e) {
-      Throwables.propagateIfInstanceOf(e.getCause(), QueryParseException.class);
+      if (e.getCause() != null) {
+        Throwables.throwIfInstanceOf(e.getCause(), QueryParseException.class);
+      }
       throw e;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 0288cb2..40fb3b6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.query.account;
 
-import com.google.common.base.Function;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
@@ -124,13 +123,9 @@
 
   public Predicate<AccountState> defaultQuery(String query) {
     return Predicate.and(
-        Lists.transform(Splitter.on(' ').omitEmptyStrings().splitToList(query),
-            new Function<String, Predicate<AccountState>>() {
-              @Override
-              public Predicate<AccountState> apply(String s) {
-                return defaultField(s);
-              }
-            }));
+        Lists.transform(
+            Splitter.on(' ').omitEmptyStrings().splitToList(query),
+            this::defaultField));
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
similarity index 69%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
index cd93ed3..38622ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
@@ -18,22 +18,21 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-@Deprecated
-class LegacyReviewerPredicate extends ChangeIndexPredicate {
+class AssigneePredicate extends ChangeIndexPredicate {
   private final Account.Id id;
 
-  LegacyReviewerPredicate(Account.Id id) {
-    super(ChangeField.LEGACY_REVIEWER, id.toString());
+  AssigneePredicate(Account.Id id) {
+    super(ChangeField.ASSIGNEE, id.toString());
     this.id = id;
   }
 
-  Account.Id getAccountId() {
-    return id;
-  }
-
   @Override
-  public boolean match(ChangeData object) throws OrmException {
-    return object.reviewers().all().contains(id);
+  public boolean match(final ChangeData object) throws OrmException {
+    if (id.get() == ChangeField.NO_ASSIGNEE) {
+      Account.Id assignee = object.change().getAssignee();
+      return assignee == null;
+    }
+    return id.equals(object.change().getAssignee());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index a260d02..3387f06 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -17,13 +17,12 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.ApprovalsUtil.sortApprovals;
+import static java.util.stream.Collectors.toList;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
@@ -36,18 +35,18 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
@@ -57,14 +56,15 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import com.google.inject.assistedinject.Assisted;
@@ -91,7 +91,10 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
 
 public class ChangeData {
   private static final int BATCH_SIZE = 50;
@@ -106,12 +109,8 @@
   }
 
   public static Map<Change.Id, ChangeData> asMap(List<ChangeData> changes) {
-    Map<Change.Id, ChangeData> result =
-        Maps.newHashMapWithExpectedSize(changes.size());
-    for (ChangeData cd : changes) {
-      result.put(cd.getId(), cd);
-    }
-    return result;
+    return changes.stream().collect(
+        Collectors.toMap(ChangeData::getId, cd -> cd));
   }
 
   public static void ensureChangeLoaded(Iterable<ChangeData> changes)
@@ -307,6 +306,7 @@
     return cd;
   }
 
+  private boolean lazyLoad = true;
   private final ReviewDb db;
   private final GitRepositoryManager repoManager;
   private final ChangeControl.GenericFactory changeControlFactory;
@@ -316,13 +316,16 @@
   private final ChangeNotes.Factory notesFactory;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final NotesMigration notesMigration;
   private final MergeabilityCache mergeabilityCache;
   private final StarredChangesUtil starredChangesUtil;
   private final Change.Id legacyId;
+  private final Map<SubmitRuleOptions, List<SubmitRecord>>
+      submitRecords = Maps.newLinkedHashMapWithExpectedSize(1);
+
   private Project.NameKey project;
   private Change change;
   private ChangeNotes notes;
@@ -334,11 +337,11 @@
   private List<PatchSetApproval> currentApprovals;
   private Map<Integer, List<String>> files;
   private Map<Integer, Optional<PatchList>> patchLists;
-  private Collection<PatchLineComment> publishedComments;
+  private Map<Integer, Optional<DiffSummary>> diffSummaries;
+  private Collection<Comment> publishedComments;
   private CurrentUser visibleTo;
   private ChangeControl changeControl;
   private List<ChangeMessage> messages;
-  private List<SubmitRecord> submitRecords;
   private Optional<ChangedLines> changedLines;
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
@@ -364,7 +367,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
@@ -382,7 +385,7 @@
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
@@ -402,7 +405,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
@@ -419,7 +422,7 @@
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
@@ -440,7 +443,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
@@ -457,7 +460,7 @@
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
@@ -479,7 +482,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
@@ -496,7 +499,7 @@
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
@@ -519,7 +522,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
@@ -538,7 +541,7 @@
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
@@ -548,6 +551,11 @@
     this.project = null;
   }
 
+  public ChangeData setLazyLoad(boolean load) {
+    lazyLoad = load;
+    return this;
+  }
+
   public ReviewDb db() {
     return db;
   }
@@ -568,10 +576,7 @@
 
   public List<String> currentFilePaths() throws OrmException {
     PatchSet ps = currentPatchSet();
-    if (ps == null) {
-      return null;
-    }
-    return filePaths(currentPatchSet);
+    return ps != null ? filePaths(ps) : null;
   }
 
   public List<String> filePaths(PatchSet ps) throws OrmException {
@@ -583,35 +588,16 @@
         return null;
       }
 
-      Optional<PatchList> p = getPatchList(c, ps);
+      Optional<DiffSummary> p = getDiffSummary(c, ps);
       if (!p.isPresent()) {
         List<String> emptyFileList = Collections.emptyList();
-        files.put(ps.getPatchSetId(), emptyFileList);
+        if (lazyLoad) {
+          files.put(ps.getPatchSetId(), emptyFileList);
+        }
         return emptyFileList;
       }
 
-      r = new ArrayList<>(p.get().getPatches().size());
-      for (PatchListEntry e : p.get().getPatches()) {
-        if (Patch.COMMIT_MSG.equals(e.getNewName())) {
-          continue;
-        }
-        switch (e.getChangeType()) {
-          case ADDED:
-          case MODIFIED:
-          case DELETED:
-          case COPIED:
-          case REWRITE:
-            r.add(e.getNewName());
-            break;
-
-          case RENAMED:
-            r.add(e.getOldName());
-            r.add(e.getNewName());
-            break;
-        }
-      }
-      Collections.sort(r);
-      r = Collections.unmodifiableList(r);
+      r = p.get().getPaths();
       files.put(psId, r);
     }
     return r;
@@ -624,35 +610,57 @@
     }
     Optional<PatchList> r = patchLists.get(psId);
     if (r == null) {
+      if (!lazyLoad) {
+        return Optional.empty();
+      }
       try {
         r = Optional.of(patchListCache.get(c, ps));
       } catch (PatchListNotAvailableException e) {
-        r = Optional.absent();
+        r = Optional.empty();
       }
       patchLists.put(psId, r);
     }
     return r;
   }
 
+  private Optional<DiffSummary> getDiffSummary(Change c, PatchSet ps) {
+    Integer psId = ps.getId().get();
+    if (diffSummaries == null) {
+      diffSummaries = new HashMap<>();
+    }
+    Optional<DiffSummary> r = diffSummaries.get(psId);
+    if (r == null) {
+      if (!lazyLoad) {
+        return Optional.empty();
+      }
+      try {
+        r = Optional.of(patchListCache.getDiffSummary(c, ps));
+      } catch (PatchListNotAvailableException e) {
+        r = Optional.empty();
+      }
+      diffSummaries.put(psId, r);
+    }
+    return r;
+  }
+
   private Optional<ChangedLines> computeChangedLines() throws OrmException {
     Change c = change();
     if (c == null) {
-      return Optional.absent();
+      return Optional.empty();
     }
     PatchSet ps = currentPatchSet();
     if (ps == null) {
-      return Optional.absent();
+      return Optional.empty();
     }
-    Optional<PatchList> p = getPatchList(c, ps);
-    if (!p.isPresent()) {
-      return Optional.absent();
-    }
-    return Optional.of(
-        new ChangedLines(p.get().getInsertions(), p.get().getDeletions()));
+    return getPatchList(c, ps).map(
+        p -> new ChangedLines(p.getInsertions(), p.getDeletions()));
   }
 
   public Optional<ChangedLines> changedLines() throws OrmException {
     if (changedLines == null) {
+      if (!lazyLoad) {
+        return Optional.empty();
+      }
       changedLines = computeChangedLines();
     }
     return changedLines;
@@ -663,7 +671,7 @@
   }
 
   public void setNoChangedLines() {
-    changedLines = Optional.absent();
+    changedLines = Optional.empty();
   }
 
   public Change.Id getId() {
@@ -703,10 +711,7 @@
   public ChangeControl changeControl(CurrentUser user) throws OrmException {
     if (changeControl != null) {
       CurrentUser oldUser = user;
-      // TODO(dborowitz): This is a hack; general CurrentUser equality would be
-      // better.
-      if (user.isIdentifiedUser() && oldUser.isIdentifiedUser()
-          && user.getAccountId().equals(oldUser.getAccountId())) {
+      if (sameUser(user, oldUser)) {
         return changeControl;
       }
       throw new IllegalStateException(
@@ -725,13 +730,26 @@
     return changeControl;
   }
 
+  private static boolean sameUser(CurrentUser a, CurrentUser b) {
+    // TODO(dborowitz): This is a hack; general CurrentUser equality would be
+    // better.
+    if (a.isInternalUser() && b.isInternalUser()) {
+      return true;
+    } else if (a instanceof AnonymousUser && b instanceof AnonymousUser) {
+      return true;
+    } else if (a.isIdentifiedUser() && b.isIdentifiedUser()) {
+      return a.getAccountId().equals(b.getAccountId());
+    }
+    return false;
+  }
+
   void cacheVisibleTo(ChangeControl ctl) {
     visibleTo = ctl.getUser();
     changeControl = ctl;
   }
 
   public Change change() throws OrmException {
-    if (change == null) {
+    if (change == null && lazyLoad) {
       reloadChange();
     }
     return change;
@@ -742,20 +760,20 @@
   }
 
   public Change reloadChange() throws OrmException {
-    if (project == null) {
-      notes = notesFactory.createFromIdOnlyWhenNoteDbDisabled(db, legacyId);
-    } else {
-      notes = notesFactory.create(db, project, legacyId);
-    }
+    notes = notesFactory.create(db, project, legacyId);
     change = notes.getChange();
     if (change == null) {
       throw new OrmException("Unable to load change " + legacyId);
     }
+    setPatchSets(null);
     return change;
   }
 
   public ChangeNotes notes() throws OrmException {
     if (notes == null) {
+      if (!lazyLoad) {
+        throw new OrmException("ChangeNotes not available, lazyLoad = false");
+      }
       notes = notesFactory.create(db, project(), legacyId);
     }
     return notes;
@@ -780,12 +798,23 @@
   public List<PatchSetApproval> currentApprovals()
       throws OrmException {
     if (currentApprovals == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
       Change c = change();
       if (c == null) {
         currentApprovals = Collections.emptyList();
       } else {
-        currentApprovals = ImmutableList.copyOf(approvalsUtil.byPatchSet(
-            db, changeControl(), c.currentPatchSetId()));
+        try {
+          currentApprovals = ImmutableList.copyOf(approvalsUtil.byPatchSet(
+              db, changeControl(), c.currentPatchSetId()));
+        } catch (OrmException e) {
+          if (e.getCause() instanceof NoSuchChangeException) {
+            currentApprovals = Collections.emptyList();
+          } else {
+            throw e;
+          }
+        }
       }
     }
     return currentApprovals;
@@ -866,17 +895,14 @@
    * @throws OrmException an error occurred reading the database.
    */
   public Collection<PatchSet> visiblePatchSets() throws OrmException {
-    Predicate<PatchSet> predicate = new Predicate<PatchSet>() {
-      @Override
-      public boolean apply(PatchSet input) {
-        try {
-          return changeControl().isPatchVisible(input, db);
-        } catch (OrmException e) {
-          return false;
-        }
+    Predicate<? super PatchSet> predicate = ps -> {
+      try {
+        return changeControl().isPatchVisible(ps, db);
+      } catch (OrmException e) {
+        return false;
       }
     };
-    return FluentIterable.from(patchSets()).filter(predicate).toList();
+    return patchSets().stream().filter(predicate).collect(toList());
   }
 
   public void setPatchSets(Collection<PatchSet> patchSets) {
@@ -908,6 +934,9 @@
   public ListMultimap<PatchSet.Id, PatchSetApproval> approvals()
       throws OrmException {
     if (allApprovals == null) {
+      if (!lazyLoad) {
+        return ImmutableListMultimap.of();
+      }
       allApprovals = approvalsUtil.byChange(db, notes());
     }
     return allApprovals;
@@ -918,17 +947,17 @@
    * @throws OrmException an error occurred reading the database.
    */
   public Optional<PatchSetApproval> getSubmitApproval()
-    throws OrmException {
-    for (PatchSetApproval psa : currentApprovals()) {
-      if (psa.isLegacySubmit()) {
-        return Optional.fromNullable(psa);
-      }
-    }
-    return Optional.absent();
+      throws OrmException {
+    return currentApprovals().stream()
+        .filter(PatchSetApproval::isLegacySubmit)
+        .findFirst();
   }
 
   public ReviewerSet reviewers() throws OrmException {
     if (reviewers == null) {
+      if (!lazyLoad) {
+        return ReviewerSet.empty();
+      }
       reviewers = approvalsUtil.getReviewers(notes(), approvals().values());
     }
     return reviewers;
@@ -944,6 +973,9 @@
 
   public List<ReviewerStatusUpdate> reviewerUpdates() throws OrmException {
     if (reviewerUpdates == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
       reviewerUpdates = approvalsUtil.getReviewerUpdates(notes());
     }
     return reviewerUpdates;
@@ -957,10 +989,13 @@
     return reviewerUpdates;
   }
 
-  public Collection<PatchLineComment> publishedComments()
+  public Collection<Comment> publishedComments()
       throws OrmException {
     if (publishedComments == null) {
-      publishedComments = plcUtil.publishedByChange(db, notes());
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      publishedComments = commentsUtil.publishedByChange(db, notes());
     }
     return publishedComments;
   }
@@ -968,17 +1003,38 @@
   public List<ChangeMessage> messages()
       throws OrmException {
     if (messages == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
       messages = cmUtil.byChange(db, notes());
     }
     return messages;
   }
 
-  public void setSubmitRecords(List<SubmitRecord> records) {
-    submitRecords = records;
+  public List<SubmitRecord> submitRecords(
+      SubmitRuleOptions options) throws OrmException {
+    List<SubmitRecord> records = submitRecords.get(options);
+    if (records == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      records = new SubmitRuleEvaluator(this)
+          .setOptions(options)
+          .evaluate();
+      submitRecords.put(options, records);
+    }
+    return records;
   }
 
-  public List<SubmitRecord> getSubmitRecords() {
-    return submitRecords;
+  @Nullable
+  public List<SubmitRecord> getSubmitRecords(
+      SubmitRuleOptions options) {
+    return submitRecords.get(options);
+  }
+
+  public void setSubmitRecords(SubmitRuleOptions options,
+      List<SubmitRecord> records) {
+    submitRecords.put(options, records);
   }
 
   public SubmitTypeRecord submitTypeRecord() throws OrmException {
@@ -1001,10 +1057,21 @@
       if (c.getStatus() == Change.Status.MERGED) {
         mergeable = true;
       } else {
-        PatchSet ps = currentPatchSet();
-        if (ps == null || !changeControl().isPatchVisible(ps, db)) {
+        if (!lazyLoad) {
           return null;
         }
+        PatchSet ps = currentPatchSet();
+        try {
+          if (ps == null || !changeControl().isPatchVisible(ps, db)) {
+            return null;
+          }
+        } catch (OrmException e) {
+          if (e.getCause() instanceof NoSuchChangeException) {
+            return null;
+          }
+          throw e;
+        }
+
         try (Repository repo = repoManager.openRepository(project())) {
           Ref ref = repo.getRefDatabase().exactRef(c.getDest().get());
           SubmitTypeRecord str = submitTypeRecord();
@@ -1029,6 +1096,9 @@
 
   public Set<Account.Id> editsByUser() throws OrmException {
     if (editsByUser == null) {
+      if (!lazyLoad) {
+        return Collections.emptySet();
+      }
       Change c = change();
       if (c == null) {
         return Collections.emptySet();
@@ -1051,13 +1121,16 @@
 
   public Set<Account.Id> draftsByUser() throws OrmException {
     if (draftsByUser == null) {
+      if (!lazyLoad) {
+        return Collections.emptySet();
+      }
       Change c = change();
       if (c == null) {
         return Collections.emptySet();
       }
       draftsByUser = new HashSet<>();
-      for (PatchLineComment sc : plcUtil.draftByChange(db, notes)) {
-        draftsByUser.add(sc.getAuthor());
+      for (Comment sc : commentsUtil.draftByChange(db, notes)) {
+        draftsByUser.add(sc.author.getId());
       }
     }
     return draftsByUser;
@@ -1065,6 +1138,9 @@
 
   public Set<Account.Id> reviewedBy() throws OrmException {
     if (reviewedBy == null) {
+      if (!lazyLoad) {
+        return Collections.emptySet();
+      }
       Change c = change();
       if (c == null) {
         return Collections.emptySet();
@@ -1094,6 +1170,9 @@
 
   public Set<String> hashtags() throws OrmException {
     if (hashtags == null) {
+      if (!lazyLoad) {
+        return Collections.emptySet();
+      }
       hashtags = notes().getHashtags();
     }
     return hashtags;
@@ -1106,6 +1185,9 @@
   @Deprecated
   public Set<Account.Id> starredBy() throws OrmException {
     if (starredByUser == null) {
+      if (!lazyLoad) {
+        return Collections.emptySet();
+      }
       starredByUser = checkNotNull(starredChangesUtil).byChange(
           legacyId, StarredChangesUtil.DEFAULT_LABEL);
     }
@@ -1119,6 +1201,9 @@
 
   public ImmutableMultimap<Account.Id, String> stars() throws OrmException {
     if (stars == null) {
+      if (!lazyLoad) {
+        return ImmutableMultimap.of();
+      }
       stars = checkNotNull(starredChangesUtil).byChange(legacyId);
     }
     return stars;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index d7c7730..e62bf48 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -16,17 +16,17 @@
 
 import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
+import com.google.common.base.Enums;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.errors.NotSignedInException;
-import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -34,9 +34,9 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
@@ -86,6 +86,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Pattern;
 
@@ -107,6 +108,7 @@
 
   public static final String FIELD_ADDED = "added";
   public static final String FIELD_AGE = "age";
+  public static final String FIELD_ASSIGNEE = "assignee";
   public static final String FIELD_AUTHOR = "author";
   public static final String FIELD_BEFORE = "before";
   public static final String FIELD_CHANGE = "change";
@@ -152,7 +154,8 @@
 
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
-
+  public static final String ARG_ID_OWNER = "owner";
+  public static final Account.Id OWNER_ACCOUNT_ID = new Account.Id(0);
 
   private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> mydef =
       new QueryBuilder.Definition<>(ChangeQueryBuilder.class);
@@ -169,7 +172,7 @@
     final ChangeNotes.Factory notesFactory;
     final ChangeData.Factory changeDataFactory;
     final FieldDef.FillArgs fillArgs;
-    final PatchLineCommentsUtil plcUtil;
+    final CommentsUtil commentsUtil;
     final AccountResolver accountResolver;
     final GroupBackend groupBackend;
     final AllProjectsName allProjectsName;
@@ -203,7 +206,7 @@
         ChangeNotes.Factory notesFactory,
         ChangeData.Factory changeDataFactory,
         FieldDef.FillArgs fillArgs,
-        PatchLineCommentsUtil plcUtil,
+        CommentsUtil commentsUtil,
         AccountResolver accountResolver,
         GroupBackend groupBackend,
         AllProjectsName allProjectsName,
@@ -223,7 +226,7 @@
         @GerritServerConfig Config cfg) {
       this(db, queryProvider, rewriter, opFactories, userFactory, self,
           capabilityControlFactory, changeControlGenericFactory, notesFactory,
-          changeDataFactory, fillArgs, plcUtil, accountResolver, groupBackend,
+          changeDataFactory, fillArgs, commentsUtil, accountResolver, groupBackend,
           allProjectsName, allUsersName, patchListCache, repoManager,
           projectCache, listChildProjects, submitDryRun, conflictsCache,
           trackingFooters, indexes != null ? indexes.getSearchIndex() : null,
@@ -243,7 +246,7 @@
         ChangeNotes.Factory notesFactory,
         ChangeData.Factory changeDataFactory,
         FieldDef.FillArgs fillArgs,
-        PatchLineCommentsUtil plcUtil,
+        CommentsUtil commentsUtil,
         AccountResolver accountResolver,
         GroupBackend groupBackend,
         AllProjectsName allProjectsName,
@@ -272,7 +275,7 @@
      this.changeControlGenericFactory = changeControlGenericFactory;
      this.changeDataFactory = changeDataFactory;
      this.fillArgs = fillArgs;
-     this.plcUtil = plcUtil;
+     this.commentsUtil = commentsUtil;
      this.accountResolver = accountResolver;
      this.groupBackend = groupBackend;
      this.allProjectsName = allProjectsName;
@@ -296,7 +299,7 @@
       return new Arguments(db, queryProvider, rewriter, opFactories, userFactory,
           Providers.of(otherUser),
           capabilityControlFactory, changeControlGenericFactory, notesFactory,
-          changeDataFactory, fillArgs, plcUtil, accountResolver, groupBackend,
+          changeDataFactory, fillArgs, commentsUtil, accountResolver, groupBackend,
           allProjectsName, allUsersName, patchListCache, repoManager,
           projectCache, listChildProjects, submitDryRun,
           conflictsCache, trackingFooters, index, indexConfig, listMembers,
@@ -364,6 +367,10 @@
     }
   }
 
+  public Arguments getArgs() {
+    return args;
+  }
+
   public ChangeQueryBuilder asUser(CurrentUser user) {
     return new ChangeQueryBuilder(builderDef, args.asUser(user));
   }
@@ -417,7 +424,8 @@
   }
 
   @Operator
-  public Predicate<ChangeData> status(String statusName) {
+  public Predicate<ChangeData> status(String statusName)
+      throws QueryParseException {
     if ("reviewed".equalsIgnoreCase(statusName)) {
       return IsReviewedPredicate.create();
     }
@@ -478,6 +486,18 @@
       return new IsMergeablePredicate(args.fillArgs);
     }
 
+    if ("assigned".equalsIgnoreCase(value)) {
+      return Predicate.not(new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE)));
+    }
+
+    if ("unassigned".equalsIgnoreCase(value)) {
+      return new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE));
+    }
+
+    if ("submittable".equalsIgnoreCase(value)) {
+      return new SubmittablePredicate(SubmitRecord.Status.OK);
+    }
+
     try {
       return status(value);
     } catch (IllegalArgumentException e) {
@@ -592,6 +612,9 @@
     // label:CodeReview=1,group=android_approvers or
     // label:CodeReview=1,android_approvers
     //  user/groups without a label will first attempt to match user
+    // Special case: votes by owners can be tracked with ",owner":
+    // label:Code-Review+2,owner
+    // label:Code-Review+2,user=owner
     String[] splitReviewer = name.split(",", 2);
     name = splitReviewer[0];        // remove all but the vote piece, e.g.'CodeReview=1'
 
@@ -601,7 +624,11 @@
 
       for (Map.Entry<String, String> pair : lblArgs.keyValue.entrySet()) {
         if (pair.getKey().equalsIgnoreCase(ARG_ID_USER)) {
-          accounts = parseAccount(pair.getValue());
+          if (pair.getValue().equals(ARG_ID_OWNER)) {
+            accounts = Collections.singleton(OWNER_ACCOUNT_ID);
+          } else {
+            accounts = parseAccount(pair.getValue());
+          }
         } else if (pair.getKey().equalsIgnoreCase(ARG_ID_GROUP)) {
           group = parseGroup(pair.getValue()).getUUID();
         } else {
@@ -616,14 +643,18 @@
               value + ")");
         }
         try {
-          accounts = parseAccount(value);
+          if (value.equals(ARG_ID_OWNER)) {
+            accounts = Collections.singleton(OWNER_ACCOUNT_ID);
+          } else {
+            accounts = parseAccount(value);
+          }
         } catch (QueryParseException qpex) {
           // If it doesn't match an account, see if it matches a group
           // (accounts get precedence)
           try {
             group = parseGroup(value).getUUID();
           } catch (QueryParseException e) {
-            throw error("Neither user nor group " + value + " found");
+            throw error("Neither user nor group " + value + " found", e);
           }
         }
       }
@@ -632,14 +663,9 @@
     // expand a group predicate into multiple user predicates
     if (group != null) {
       Set<Account.Id> allMembers =
-          new HashSet<>(Lists.transform(
-              args.listMembers.get().setRecursive(true).apply(group),
-              new Function<AccountInfo, Account.Id>() {
-                @Override
-                public Account.Id apply(AccountInfo accountInfo) {
-                  return new Account.Id(accountInfo._accountId);
-                }
-              }));
+          args.listMembers.get().setRecursive(true).apply(group).stream()
+              .map(a -> new Account.Id(a._accountId))
+              .collect(toSet());
       int maxLimit = args.indexConfig.maxLimit();
       if (allMembers.size() > maxLimit) {
         // limit the number of query terms otherwise Gerrit will barf
@@ -649,9 +675,33 @@
       }
     }
 
-    return new LabelPredicate(args.projectCache,
-        args.changeControlGenericFactory, args.userFactory, args.db,
-        name, accounts, group);
+    // If the vote piece looks like Code-Review=NEED with a valid non-numeric
+    // submit record status, interpret as a submit record query.
+    int eq = name.indexOf('=');
+    if (args.getSchema().hasField(ChangeField.SUBMIT_RECORD) && eq > 0) {
+      String statusName = name.substring(eq + 1).toUpperCase();
+      if (!isInt(statusName)) {
+        SubmitRecord.Label.Status status = Enums.getIfPresent(
+            SubmitRecord.Label.Status.class, statusName).orNull();
+        if (status == null) {
+          throw error("Invalid label status " + statusName + " in " + name);
+        }
+        return SubmitRecordPredicate.create(
+            name.substring(0, eq), status, accounts);
+      }
+    }
+
+    return new LabelPredicate(args, name, accounts, group);
+  }
+
+  private static boolean isInt(String s) {
+    if (s == null) {
+      return false;
+    }
+    if (s.startsWith("+")) {
+      s = s.substring(1);
+    }
+    return Ints.tryParse(s) != null;
   }
 
   @Operator
@@ -670,8 +720,7 @@
     return starredby(parseAccount(who));
   }
 
-  private Predicate<ChangeData> starredby(Set<Account.Id> who)
-      throws QueryParseException {
+  private Predicate<ChangeData> starredby(Set<Account.Id> who) {
     List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
     for (Account.Id id : who) {
       p.add(starredby(id));
@@ -679,25 +728,8 @@
     return Predicate.or(p);
   }
 
-  @SuppressWarnings("deprecation")
-  private Predicate<ChangeData> starredby(Account.Id who)
-      throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.STAR)) {
-      return new StarPredicate(who, StarredChangesUtil.DEFAULT_LABEL);
-    }
-
-    if (args.getSchema().hasField(ChangeField.STARREDBY)) {
-      return new IsStarredByPredicate(who);
-    }
-
-    try {
-      // starred changes are not contained in the index, we must read them from
-      // git
-      return new IsStarredByLegacyPredicate(who, args.starredChangesUtil
-          .byAccount(who, StarredChangesUtil.DEFAULT_LABEL));
-    } catch (OrmException e) {
-      throw new QueryParseException("Failed to query starred changes.", e);
-    }
+  private Predicate<ChangeData> starredby(Account.Id who) {
+    return new StarPredicate(who, StarredChangesUtil.DEFAULT_LABEL);
   }
 
   @Operator
@@ -736,11 +768,8 @@
     return Predicate.or(p);
   }
 
-  @SuppressWarnings("deprecation")
   private Predicate<ChangeData> draftby(Account.Id who) {
-    return args.getSchema().hasField(ChangeField.DRAFTBY)
-        ? new HasDraftByPredicate(who)
-        : new HasDraftByLegacyPredicate(args, who);
+    return new HasDraftByPredicate(who);
   }
 
   @Operator
@@ -802,6 +831,20 @@
   }
 
   @Operator
+  public Predicate<ChangeData> assignee(String who) throws QueryParseException,
+      OrmException {
+    return assignee(parseAccount(who));
+  }
+
+  private Predicate<ChangeData> assignee(Set<Account.Id> who) {
+    List<AssigneePredicate> p = Lists.newArrayListWithCapacity(who.size());
+    for (Account.Id id : who) {
+      p.add(new AssigneePredicate(id));
+    }
+    return Predicate.or(p);
+  }
+
+  @Operator
   public Predicate<ChangeData> ownerin(String group)
       throws QueryParseException {
     GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
@@ -957,6 +1000,17 @@
     return new CommitterPredicate(who);
   }
 
+  @Operator
+  public Predicate<ChangeData> submittable(String str)
+      throws QueryParseException {
+    SubmitRecord.Status status = Enums.getIfPresent(
+        SubmitRecord.Status.class, str.toUpperCase()).orNull();
+    if (status == null) {
+      throw error("invalid value for submittable:" + str);
+    }
+    return new SubmittablePredicate(status);
+  }
+
   @Override
   protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
     if (query.startsWith("refs/")) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 1c92ecf..1ae8591 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.ArrayList;
@@ -63,7 +64,8 @@
     return status.name().toLowerCase();
   }
 
-  public static Predicate<ChangeData> parse(String value) {
+  public static Predicate<ChangeData> parse(String value)
+      throws QueryParseException {
     String lower = value.toLowerCase();
     NavigableMap<String, Predicate<ChangeData>> head =
         PREDICATES.tailMap(lower, true);
@@ -75,7 +77,7 @@
         return e.getValue();
       }
     }
-    throw new IllegalArgumentException("invalid change status: " + value);
+    throw new QueryParseException("invalid change status: " + value);
   }
 
   public static Predicate<ChangeData> open() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index 48d6e05..1cb6333 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
@@ -41,8 +41,8 @@
         return true;
       }
     }
-    for (PatchLineComment c : cd.publishedComments()) {
-      if (Objects.equals(c.getAuthor(), id)) {
+    for (Comment c : cd.publishedComments()) {
+      if (Objects.equals(c.author.getId(), id)) {
         return true;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 69bc2ca..26dbe23 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.OrPredicate;
 import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
@@ -47,16 +48,23 @@
 import java.util.Set;
 
 class ConflictsPredicate extends OrPredicate<ChangeData> {
+  // UI code may depend on this string, so use caution when changing.
+  private static final String TOO_MANY_FILES =
+      "too many files to find conflicts";
+
   private final String value;
 
   ConflictsPredicate(Arguments args, String value, List<Change> changes)
-      throws OrmException {
+      throws QueryParseException, OrmException {
     super(predicates(args, value, changes));
     this.value = value;
   }
 
   private static List<Predicate<ChangeData>> predicates(final Arguments args,
-      String value, List<Change> changes) throws OrmException {
+      String value, List<Change> changes)
+      throws QueryParseException, OrmException {
+    int indexTerms = 0;
+
     List<Predicate<ChangeData>> changePredicates =
         Lists.newArrayListWithCapacity(changes.size());
     final Provider<ReviewDb> db = args.db;
@@ -64,6 +72,16 @@
       final ChangeDataCache changeDataCache = new ChangeDataCache(
           c, db, args.changeDataFactory, args.projectCache);
       List<String> files = listFiles(c, args, changeDataCache);
+      indexTerms += 3 + files.size();
+      if (indexTerms > args.indexConfig.maxTerms()) {
+        // Short-circuit with a nice error message if we exceed the index
+        // backend's term limit. This assumes that "conflicts:foo" is the entire
+        // query; if there are more terms in the input, we might not
+        // short-circuit here, which will result in a more generic error message
+        // later on in the query parsing.
+        throw new QueryParseException(TOO_MANY_FILES);
+      }
+
       List<Predicate<ChangeData>> filePredicates =
           Lists.newArrayListWithCapacity(files.size());
       for (String file : files) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index e752b05..0adf78f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -43,7 +43,7 @@
 
   EqualsLabelPredicate(LabelPredicate.Args args, String label, int expVal,
       Account.Id account) {
-    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account));
+    super(args.field, ChangeField.formatLabel(label, expVal, account));
     this.ccFactory = args.ccFactory;
     this.projectCache = args.projectCache;
     this.userFactory = args.userFactory;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java
deleted file mode 100644
index 45a00c6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-@Deprecated
-class HasDraftByLegacyPredicate extends ChangeOperatorPredicate
-    implements ChangeDataSource {
-  private final Arguments args;
-  private final Account.Id accountId;
-
-  HasDraftByLegacyPredicate(Arguments args,
-      Account.Id accountId) {
-    super(ChangeQueryBuilder.FIELD_DRAFTBY, accountId.toString());
-    this.args = args;
-    this.accountId = accountId;
-  }
-
-  @Override
-  public boolean match(final ChangeData object) throws OrmException {
-    return !args.plcUtil
-        .draftByChangeAuthor(args.db.get(), object.notes(), accountId)
-        .isEmpty();
-  }
-
-  @Override
-  public ResultSet<ChangeData> read() throws OrmException {
-    Set<Change.Id> ids = new HashSet<>();
-    for (PatchLineComment sc :
-        args.plcUtil.draftByAuthor(args.db.get(), accountId)) {
-      ids.add(sc.getKey().getParentKey().getParentKey().getParentKey());
-    }
-
-    List<ChangeData> r = new ArrayList<>(ids.size());
-    // TODO Don't load the changes directly from the database, but provide
-    // project name + change ID to changeDataFactory, or delete this predicate.
-    for (Change c : args.db.get().changes().get(ids)) {
-      r.add(args.changeDataFactory.create(args.db.get(), c));
-    }
-    return new ListResultSet<>(r);
-  }
-
-  @Override
-  public boolean hasChange() {
-    return false;
-  }
-
-  @Override
-  public int getCardinality() {
-    return 20;
-  }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 6aa33352..6721052 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -15,18 +15,15 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.index.change.ChangeField.SUBMISSIONID;
 import static com.google.gerrit.server.query.Predicate.and;
 import static com.google.gerrit.server.query.Predicate.not;
 import static com.google.gerrit.server.query.Predicate.or;
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -161,7 +158,7 @@
   }
 
   public Iterable<ChangeData> byCommitsOnBranchNotMerged(Repository repo,
-      ReviewDb db, Branch.NameKey branch, List<String> hashes)
+      ReviewDb db, Branch.NameKey branch, Collection<String> hashes)
       throws OrmException, IOException {
     return byCommitsOnBranchNotMerged(repo, db, branch, hashes,
         // Account for all commit predicates plus ref, project, status.
@@ -170,7 +167,7 @@
 
   @VisibleForTesting
   Iterable<ChangeData> byCommitsOnBranchNotMerged(Repository repo, ReviewDb db,
-      Branch.NameKey branch, List<String> hashes, int indexLimit)
+      Branch.NameKey branch, Collection<String> hashes, int indexLimit)
       throws OrmException, IOException {
     if (hashes.size() > indexLimit) {
       return byCommitsOnBranchNotMergedFromDatabase(repo, db, branch, hashes);
@@ -180,7 +177,7 @@
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
       Repository repo, final ReviewDb db, final Branch.NameKey branch,
-      List<String> hashes) throws OrmException, IOException {
+      Collection<String> hashes) throws OrmException, IOException {
     Set<Change.Id> changeIds = Sets.newHashSetWithExpectedSize(hashes.size());
     String lastPrefix = null;
     for (Ref ref :
@@ -199,24 +196,18 @@
       }
     }
 
-    return Lists.transform(notesFactory.create(db, branch.getParentKey(),
-        changeIds, new com.google.common.base.Predicate<ChangeNotes>() {
-          @Override
-          public boolean apply(ChangeNotes notes) {
-            Change c = notes.getChange();
+    List<ChangeNotes> notes = notesFactory.create(
+        db, branch.getParentKey(), changeIds,
+        cn -> {
+            Change c = cn.getChange();
             return c.getDest().equals(branch)
                 && c.getStatus() != Change.Status.MERGED;
-          }
-        }), new Function<ChangeNotes, ChangeData>() {
-          @Override
-          public ChangeData apply(ChangeNotes notes) {
-            return changeDataFactory.create(db, notes);
-          }
         });
+    return Lists.transform(notes, n -> changeDataFactory.create(db, n));
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromIndex(
-      Branch.NameKey branch, List<String> hashes) throws OrmException {
+      Branch.NameKey branch, Collection<String> hashes) throws OrmException {
     return query(and(
         ref(branch),
         project(branch.getParentKey()),
@@ -224,7 +215,7 @@
         or(commits(hashes))));
   }
 
-  private static List<Predicate<ChangeData>> commits(List<String> hashes) {
+  private static List<Predicate<ChangeData>> commits(Collection<String> hashes) {
     List<Predicate<ChangeData>> commits = new ArrayList<>(hashes.size());
     for (String s : hashes) {
       commits.add(commit(s));
@@ -276,7 +267,7 @@
   }
 
   public List<ChangeData> bySubmissionId(String cs) throws OrmException {
-    if (Strings.isNullOrEmpty(cs) || !schema().hasField(SUBMISSIONID)) {
+    if (Strings.isNullOrEmpty(cs)) {
       return Collections.emptyList();
     }
     return query(new SubmissionIdPredicate(cs));
@@ -290,9 +281,4 @@
     }
     return query(and(project(project), or(groupPredicates)));
   }
-
-  @SuppressWarnings("deprecation")
-  public List<ChangeData> byIsStarred(Account.Id id) throws OrmException {
-    return query(new IsStarredByPredicate(id));
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByLegacyPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByLegacyPredicate.java
deleted file mode 100644
index 19cbd23..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByLegacyPredicate.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.common.collect.Lists;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.OrPredicate;
-import com.google.gerrit.server.query.Predicate;
-
-import java.util.List;
-import java.util.Set;
-
-@Deprecated
-class IsStarredByLegacyPredicate extends OrPredicate<ChangeData> {
-  private static List<Predicate<ChangeData>> predicates(Set<Change.Id> ids) {
-    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(ids.size());
-    for (Change.Id id : ids) {
-      r.add(new LegacyChangeIdPredicate(id));
-    }
-    return r;
-  }
-
-  private final Account.Id accountId;
-  private final Set<Change.Id> starredChanges;
-
-  IsStarredByLegacyPredicate(Account.Id accountId,
-      Set<Change.Id> starredChanges) {
-    super(predicates(starredChanges));
-    this.accountId = accountId;
-    this.starredChanges = starredChanges;
-  }
-
-  @Override
-  public boolean match(final ChangeData object) {
-    return starredChanges.contains(object.getId());
-  }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
-
-  @Override
-  public String toString() {
-    return ChangeQueryBuilder.FIELD_STARREDBY + ":" + accountId.toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java
deleted file mode 100644
index 929ed18..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
-
-@Deprecated
-class IsStarredByPredicate extends ChangeIndexPredicate {
-  private final Account.Id accountId;
-
-  IsStarredByPredicate(Account.Id accountId) {
-    super(ChangeField.STARREDBY, accountId.toString());
-    this.accountId = accountId;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) throws OrmException {
-    return cd.starredBy().contains(accountId);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-
-  @Override
-  public String toString() {
-    return ChangeQueryBuilder.FIELD_STARREDBY + ":" + accountId;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 2f815b2..9bed4b5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -19,6 +19,8 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.OrPredicate;
@@ -36,6 +38,7 @@
   private static final int MAX_LABEL_VALUE = 4;
 
   static class Args {
+    final FieldDef<ChangeData, ?> field;
     final ProjectCache projectCache;
     final ChangeControl.GenericFactory ccFactory;
     final IdentifiedUser.GenericFactory userFactory;
@@ -45,6 +48,7 @@
     final AccountGroup.UUID group;
 
     private Args(
+        FieldDef<ChangeData, ?> field,
         ProjectCache projectCache,
         ChangeControl.GenericFactory ccFactory,
         IdentifiedUser.GenericFactory userFactory,
@@ -52,6 +56,7 @@
         String value,
         Set<Account.Id> accounts,
         AccountGroup.UUID group) {
+      this.field = field;
       this.projectCache = projectCache;
       this.ccFactory = ccFactory;
       this.userFactory = userFactory;
@@ -76,11 +81,12 @@
 
   private final String value;
 
-  LabelPredicate(ProjectCache projectCache,
-      ChangeControl.GenericFactory ccFactory,
-      IdentifiedUser.GenericFactory userFactory, Provider<ReviewDb> dbProvider,
-      String value, Set<Account.Id> accounts, AccountGroup.UUID group) {
-    super(predicates(new Args(projectCache, ccFactory, userFactory, dbProvider,
+  @SuppressWarnings("deprecation")
+  LabelPredicate(ChangeQueryBuilder.Arguments a, String value,
+      Set<Account.Id> accounts, AccountGroup.UUID group) {
+    super(predicates(new Args(
+        a.getSchema().getField(ChangeField.LABEL2, ChangeField.LABEL).get(),
+        a.projectCache, a.changeControlGenericFactory, a.userFactory, a.db,
         value, accounts, group)));
     this.value = value;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 496eff6..5e08ee3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -307,8 +307,7 @@
             includeApprovals ? d.approvals().asMap() : null,
             includeFiles, d.change(), labelTypes);
         for (PatchSetAttribute attribute : c.patchSets) {
-          eventFactory.addPatchSetComments(
-              attribute, d.publishedComments());
+          eventFactory.addPatchSetComments(attribute, d.publishedComments());
         }
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
index 69a392b..62ca0e0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
@@ -14,6 +14,11 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -21,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.QueryResult;
 import com.google.gwtorm.server.OrmException;
@@ -101,7 +107,7 @@
         String op = m.group(1);
         throw new AuthException("Must be signed-in to use " + op);
       }
-      throw new BadRequestException(e.getMessage());
+      throw new BadRequestException(e.getMessage(), e);
     }
     return out.size() == 1 ? out.get(0) : out;
   }
@@ -121,7 +127,13 @@
 
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = imp.query(qb.parse(queries));
+    boolean requireLazyLoad =
+        containsAnyOf(options, ImmutableSet.of(DETAILED_LABELS, LABELS))
+            && !qb.getArgs().getSchema()
+                .hasField(ChangeField.STORED_SUBMIT_RECORD_LENIENT);
     List<List<ChangeInfo>> res = json.create(options)
+        .lazyLoad(requireLazyLoad
+            || containsAnyOf(options, ChangeJson.REQUIRE_LAZY_LOAD))
         .formatQueryResults(results);
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
@@ -131,4 +143,10 @@
     }
     return res;
   }
+
+  private static boolean containsAnyOf(
+      EnumSet<ListChangesOption> set,
+      ImmutableSet<ListChangesOption> toFind) {
+    return !Sets.intersection(toFind, set).isEmpty();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index 1c4fbbb..53834a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -26,21 +26,16 @@
 import java.util.List;
 
 class ReviewerPredicate extends ChangeIndexPredicate {
-  @SuppressWarnings("deprecation")
   static Predicate<ChangeData> create(Arguments args, Account.Id id) {
     List<Predicate<ChangeData>> and = new ArrayList<>(2);
-    if (args.getSchema().hasField(ChangeField.REVIEWER)) {
-      ReviewerStateInternal[] states = ReviewerStateInternal.values();
-      List<Predicate<ChangeData>> or = new ArrayList<>(states.length - 1);
-      for (ReviewerStateInternal state : states) {
-        if (state != ReviewerStateInternal.REMOVED) {
-          or.add(new ReviewerPredicate(state, id));
-        }
+    ReviewerStateInternal[] states = ReviewerStateInternal.values();
+    List<Predicate<ChangeData>> or = new ArrayList<>(states.length - 1);
+    for (ReviewerStateInternal state : states) {
+      if (state != ReviewerStateInternal.REMOVED) {
+        or.add(new ReviewerPredicate(state, id));
       }
-      and.add(Predicate.or(or));
-    } else {
-      and.add(new LegacyReviewerPredicate(id));
     }
+    and.add(Predicate.or(or));
 
     // TODO(dborowitz): This really belongs much higher up e.g. QueryProcessor.
     if (!args.allowsDrafts) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
new file mode 100644
index 0000000..ec3c56f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gwtorm.server.OrmException;
+
+import java.util.Set;
+
+class SubmitRecordPredicate extends ChangeIndexPredicate {
+  static Predicate<ChangeData> create(String label,
+      SubmitRecord.Label.Status status, Set<Account.Id> accounts) {
+    String lowerLabel = label.toLowerCase();
+    if (accounts == null || accounts.isEmpty()) {
+      return new SubmitRecordPredicate(status.name() + ',' + lowerLabel);
+    }
+    return Predicate.or(
+        accounts.stream()
+            .map(a -> new SubmitRecordPredicate(
+                status.name() + ',' + lowerLabel + ',' + a.get()))
+            .collect(toList()));
+  }
+
+  private SubmitRecordPredicate(String value) {
+    super(ChangeField.SUBMIT_RECORD, value);
+  }
+
+  @Override
+  public boolean match(ChangeData in) throws OrmException {
+    return ChangeField.formatSubmitRecordValues(in).contains(getValue());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
similarity index 64%
copy from gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index cd93ed3..8782cfd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -14,26 +14,22 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-@Deprecated
-class LegacyReviewerPredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+class SubmittablePredicate extends ChangeIndexPredicate {
+  private final SubmitRecord.Status status;
 
-  LegacyReviewerPredicate(Account.Id id) {
-    super(ChangeField.LEGACY_REVIEWER, id.toString());
-    this.id = id;
-  }
-
-  Account.Id getAccountId() {
-    return id;
+  SubmittablePredicate(SubmitRecord.Status status) {
+    super(ChangeField.SUBMIT_RECORD, status.name());
+    this.status = status;
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
-    return object.reviewers().all().contains(id);
+  public boolean match(ChangeData cd) throws OrmException {
+    return cd.submitRecords(ChangeField.SUBMIT_RULE_OPTIONS_STRICT).stream()
+        .anyMatch(r -> r.status == status);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 7c7417a..b486bf1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -165,8 +165,9 @@
       grant(config, heads, Permission.FORGE_COMMITTER, admin, owners);
       grant(config, heads, Permission.EDIT_TOPIC_NAME, true, admin, owners);
 
-      grant(config, tags, Permission.PUSH_TAG, admin, owners);
-      grant(config, tags, Permission.PUSH_SIGNED_TAG, admin, owners);
+      grant(config, tags, Permission.CREATE, admin, owners);
+      grant(config, tags, Permission.CREATE_TAG, admin, owners);
+      grant(config, tags, Permission.CREATE_SIGNED_TAG, admin, owners);
 
       grant(config, magic, Permission.PUSH, registered);
       grant(config, magic, Permission.PUSH_MERGE, registered);
@@ -174,6 +175,7 @@
       meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
       grant(config, meta, Permission.READ, admin, owners);
       grant(config, meta, cr, -2, 2, admin, owners);
+      grant(config, meta, Permission.CREATE, admin, owners);
       grant(config, meta, Permission.PUSH, admin, owners);
       grant(config, meta, Permission.SUBMIT, admin, owners);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java
index 9dee9f5..a2046b5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java
@@ -18,6 +18,8 @@
 
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
 import com.google.gwtorm.jdbc.Database;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Key;
@@ -37,5 +39,6 @@
         .to(database)
         .in(SINGLETON);
     bind(database).toProvider(ReviewDbDatabaseProvider.class);
+    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
new file mode 100644
index 0000000..2d9714f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.git.ProjectConfig.ACCESS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gwtorm.server.OrmException;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+
+public class ProjectConfigSchemaUpdate extends VersionedMetaData {
+
+  private final MetaDataUpdate update;
+  private Config config;
+  private boolean updated;
+
+  public static ProjectConfigSchemaUpdate read(MetaDataUpdate update)
+      throws IOException, ConfigInvalidException {
+    ProjectConfigSchemaUpdate r = new ProjectConfigSchemaUpdate(update);
+    r.load(update);
+    return r;
+  }
+
+  private ProjectConfigSchemaUpdate(MetaDataUpdate update) {
+    this.update = update;
+  }
+
+  @Override
+  protected String getRefName() {
+    return RefNames.REFS_CONFIG;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    config = readConfig(ProjectConfig.PROJECT_CONFIG);
+  }
+
+  public void removeForceFromPermission(String name) {
+    for (String subsection : config.getSubsections(ACCESS)) {
+      Set<String> names = config.getNames(ACCESS, subsection);
+      if (names.contains(name)) {
+        List<String> values =
+            Arrays.stream(config.getStringList(ACCESS, subsection, name))
+                .map(r -> {
+                      PermissionRule rule = PermissionRule.fromString(r, false);
+                      if (rule.getForce()) {
+                        rule.setForce(false);
+                        updated = true;
+                      }
+                      return rule.asString(false);
+                    })
+                .collect(toList());
+        config.setStringList(ACCESS, subsection, name, values);
+      }
+    }
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit)
+      throws IOException, ConfigInvalidException {
+    saveConfig(ProjectConfig.PROJECT_CONFIG, config);
+    return true;
+  }
+
+  public void save(PersonIdent personIdent, String commitMessage)
+      throws OrmException {
+    if (!updated) {
+      return;
+    }
+
+    update.getCommitBuilder().setAuthor(personIdent);
+    update.getCommitBuilder().setCommitter(personIdent);
+    update.setMessage(commitMessage);
+    try {
+      commit(update);
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
index 0b7e8b0..e1b1185 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
 import com.google.gerrit.reviewdb.client.SystemConfig;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -116,6 +117,11 @@
     }
   }
 
+  @VisibleForTesting
+  public SchemaVersion getLatestSchemaVersion() {
+    return updater.get();
+  }
+
   private CurrentSchemaVersion getSchemaVersion(final ReviewDb db) {
     try {
       return db.schemaVersion().get(new CurrentSchemaVersion.Key());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index 7217fd0..2da2031 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Lists;
 import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -33,7 +34,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_129> C = Schema_129.class;
+  public static final Class<Schema_135> C = Schema_135.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
@@ -61,6 +62,11 @@
     return versionNbr;
   }
 
+  @VisibleForTesting
+  public final SchemaVersion getPrior() {
+    return prior.get();
+  }
+
   public final void check(UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
       throws OrmException, SQLException {
     if (curr.versionNbr == versionNbr) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java
index 9fdec25..cd42e75 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java
@@ -136,6 +136,8 @@
           p.reviewCategoryStrategy =
               toReviewCategoryStrategy(rs.getString(14));
           p.muteCommonPathPrefixes = toBoolean(rs.getString(15));
+          p.defaultBaseForMerges =
+              GeneralPreferencesInfo.defaults().defaultBaseForMerges;
           imports.put(accountId, p);
         }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
index 16f0bcf..895c905 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.common.base.Function;
+import static java.util.Comparator.comparing;
+
 import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.Multimap;
@@ -124,13 +125,7 @@
 
   private Collection<AccountSshKey> fixInvalidSequenceNumbers(
       Collection<AccountSshKey> keys) {
-    Ordering<AccountSshKey> o =
-        Ordering.natural().onResultOf(new Function<AccountSshKey, Integer>() {
-          @Override
-          public Integer apply(AccountSshKey sshKey) {
-            return sshKey.getKey().get();
-          }
-        });
+    Ordering<AccountSshKey> o = Ordering.from(comparing(k -> k.getKey().get()));
     List<AccountSshKey> fixedKeys = new ArrayList<>(keys);
     AccountSshKey minKey = o.min(keys);
     while (minKey.getKey().get() <= 0) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java
new file mode 100644
index 0000000..cee21bd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+
+public class Schema_130 extends SchemaVersion {
+  private static final String COMMIT_MSG =
+      "Remove force option from 'Push Annotated Tag' permission\n"
+      + "\n"
+      + "The force option on 'Push Annotated Tag' had no effect and is no longer\n"
+      + "supported.";
+
+  private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_130(Provider<Schema_129> prior,
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    for (Project.NameKey projectName : repoManager.list()) {
+      try (Repository git = repoManager.openRepository(projectName);
+          MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
+              projectName, git)) {
+        ProjectConfigSchemaUpdate cfg = ProjectConfigSchemaUpdate.read(md);
+        cfg.removeForceFromPermission("pushTag");
+        cfg.save(serverUser, COMMIT_MSG);
+      } catch (ConfigInvalidException | IOException ex) {
+        throw new OrmException("Cannot migrate project " + projectName, ex);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java
new file mode 100644
index 0000000..a2ba03c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+
+public class Schema_131 extends SchemaVersion {
+  private static final String COMMIT_MSG =
+      "Rename 'Push Annotated/Signed Tag' permission to 'Create Annotated/Signed Tag'";
+
+  private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_131(Provider<Schema_130> prior,
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    for (Project.NameKey projectName : repoManager.list()) {
+      try (Repository git = repoManager.openRepository(projectName);
+          MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
+              projectName, git)) {
+        ProjectConfig config = ProjectConfig.read(md);
+        if (config.hasLegacyPermissions()) {
+          md.getCommitBuilder().setAuthor(serverUser);
+          md.getCommitBuilder().setCommitter(serverUser);
+          md.setMessage(COMMIT_MSG);
+          config.commit(md);
+        }
+      } catch (ConfigInvalidException | IOException ex) {
+        throw new OrmException("Cannot migrate project " + projectName, ex);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_132.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_132.java
index 9bcabc3..7c1cde8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_132.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.schema;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
+public class Schema_132 extends SchemaVersion {
+  @Inject
+  Schema_132(Provider<Schema_131> prior) {
+    super(prior);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_133.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_133.java
index 9bcabc3..31d330b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_133.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.schema;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
+public class Schema_133 extends SchemaVersion {
+  @Inject
+  Schema_133(Provider<Schema_132> prior) {
+    super(prior);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_134.java
similarity index 63%
copy from gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_134.java
index 9bcabc3..fa01ff3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_134.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 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.
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.schema;
 
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
 
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
+public class Schema_134 extends SchemaVersion {
+  @Inject
+  Schema_134(Provider<Schema_133> prior) {
+    super(prior);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_135.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_135.java
new file mode 100644
index 0000000..92f150f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_135.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+import java.util.Set;
+import java.util.stream.Stream;
+
+public class Schema_135 extends SchemaVersion {
+  private static final String COMMIT_MSG =
+      "Allow admins and project owners to create refs/meta/config";
+
+  private final GitRepositoryManager repoManager;
+  private final AllProjectsName allProjectsName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_135(Provider<Schema_134> prior,
+      GitRepositoryManager repoManager,
+      AllProjectsName allProjectsName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allProjectsName = allProjectsName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    try (Repository git = repoManager.openRepository(allProjectsName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
+            allProjectsName, git)) {
+      ProjectConfig config = ProjectConfig.read(md);
+
+      AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
+      Permission createRefsMetaConfigPermission =
+          meta.getPermission(Permission.CREATE, true);
+
+      Set<GroupReference> groups =
+          Stream.concat(
+                  config
+                      .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+                      .getPermission(GlobalCapability.ADMINISTRATE_SERVER, true)
+                      .getRules()
+                      .stream()
+                      .map(PermissionRule::getGroup),
+                  Stream.of(SystemGroupBackend.getGroup(PROJECT_OWNERS)))
+              .filter(g -> createRefsMetaConfigPermission.getRule(g) == null)
+              .collect(toSet());
+
+      for (GroupReference group : groups) {
+        createRefsMetaConfigPermission
+            .add(new PermissionRule(config.resolve(group)));
+      }
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MSG);
+      config.commit(md);
+    } catch (ConfigInvalidException | IOException ex) {
+      throw new OrmException(ex);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java
deleted file mode 100644
index 2d1e1fa..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-import java.io.IOException;
-
-public class GitUtil {
-
-  /**
-   * @param git
-   * @param commitId
-   * @param parentNum
-   * @return the {@code paretNo} parent of given commit or {@code null}
-   *             when {@code parentNo} exceed number of {@code commitId} parents.
-   * @throws IncorrectObjectTypeException
-   *             the supplied id is not a commit or an annotated tag.
-   * @throws IOException
-   *             a pack file or loose object could not be read.
-   */
-  public static RevCommit getParent(Repository git,
-      ObjectId commitId, int parentNum) throws IOException {
-    try (RevWalk walk = new RevWalk(git)) {
-      RevCommit commit = walk.parseCommit(commitId);
-      if (commit.getParentCount() > parentNum) {
-        return commit.getParent(parentNum);
-      }
-    }
-    return null;
-  }
-
-  private GitUtil() {
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
index fab0b34..030383a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
@@ -58,6 +58,16 @@
         Short.parseShort(text.substring(e + 1), text.length()));
   }
 
+  public static StringBuilder appendTo(StringBuilder sb, String label,
+      short value) {
+    if (value == (short) 0) {
+      return sb.append('-').append(label);
+    } else if (value < 0) {
+      return sb.append(label).append(value);
+    }
+    return sb.append(label).append('+').append(value);
+  }
+
   public static LabelVote create(String label, short value) {
     return new AutoValue_LabelVote(LabelType.checkNameInternal(label), value);
   }
@@ -70,13 +80,9 @@
   public abstract short value();
 
   public String format() {
-    if (value() == (short) 0) {
-      return '-' + label();
-    } else if (value() < 0) {
-      return label() + value();
-    } else {
-      return label() + '+' + value();
-    }
+    // Max short string length is "-32768".length() == 6.
+    return appendTo(new StringBuilder(label().length() + 6), label(), value())
+        .toString();
   }
 
   public String formatWithEquals() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java
index 6feb182..f4719aa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.util;
 
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
@@ -27,25 +29,34 @@
  * Each call to {@link #open()} opens a new {@link ReviewDb}, so this class
  * should only be used in a bounded try/finally block.
  * <p>
- * The user in the request context is {@link InternalUser}.
+ * The user in the request context is {@link InternalUser} or the
+ * {@link IdentifiedUser} associated to the userId passed as parameter.
  */
 @Singleton
 public class OneOffRequestContext {
   private final InternalUser.Factory userFactory;
   private final SchemaFactory<ReviewDb> schemaFactory;
   private final ThreadLocalRequestContext requestContext;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
   @Inject
   OneOffRequestContext(InternalUser.Factory userFactory,
       SchemaFactory<ReviewDb> schemaFactory,
-      ThreadLocalRequestContext requestContext) {
+      ThreadLocalRequestContext requestContext,
+      IdentifiedUser.GenericFactory identifiedUserFactory) {
     this.userFactory = userFactory;
     this.schemaFactory = schemaFactory;
     this.requestContext = requestContext;
+    this.identifiedUserFactory = identifiedUserFactory;
   }
 
   public ManualRequestContext open() throws OrmException {
     return new ManualRequestContext(userFactory.create(),
         schemaFactory, requestContext);
   }
+
+  public ManualRequestContext openAs(Account.Id userId) throws OrmException {
+    return new ManualRequestContext(identifiedUserFactory.create(userId),
+        schemaFactory, requestContext);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RegexListSearcher.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RegexListSearcher.java
index 0a99a8a..bbc97df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RegexListSearcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RegexListSearcher.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.base.Function;
-import com.google.common.base.Predicate;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
@@ -94,12 +93,7 @@
 
     return Iterables.filter(
         list.subList(begin, end),
-        new Predicate<T>() {
-          @Override
-          public boolean apply(T in) {
-            return pattern.run(RegexListSearcher.this.apply(in));
-          }
-        });
+        x -> pattern.run(apply(x)));
   }
 
   public boolean hasMatch(List<T> list) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
index 13142fa..ac69ecf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -130,7 +130,7 @@
           try {
             wrapped.call();
           } catch (Exception e) {
-            Throwables.propagateIfPossible(e);
+            Throwables.throwIfUnchecked(e);
             throw new RuntimeException(e); // Not possible.
           }
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/AssigneeValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
new file mode 100644
index 0000000..5d1191c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.validators;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+
+/**
+ * Listener to provide validation of assignees.
+ */
+@ExtensionPoint
+public interface AssigneeValidationListener {
+  /**
+   * Invoked by Gerrit before the assignee of a change is modified.
+   *
+   * @param change the change on which the assignee is changed
+   * @param assignee the new assignee. Null if removed
+   * @throws ValidationException if validation fails
+   */
+  void validateAssignee(Change change, Account assignee) throws ValidationException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
index b2899c1..667ef0d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
@@ -14,9 +14,10 @@
 
 package com.google.gerrit.server.validators;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.EmailHeader;
+import com.google.gerrit.server.mail.send.EmailHeader;
 
 import java.util.Map;
 import java.util.Set;
@@ -32,11 +33,12 @@
   class Args {
     // in arguments
     public String messageClass;
+    @Nullable public String htmlBody;
 
     // in/out arguments
     public Address smtpFromAddress;
     public Set<Address> smtpRcptTo;
-    public String body;
+    public String body; // The text body of the email.
     public Map<String, EmailHeader> headers;
   }
 
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java b/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java
index 1dbdb68..a855868 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java
@@ -14,8 +14,10 @@
 
 package gerrit;
 
+import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListEntry;
 
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
@@ -24,6 +26,8 @@
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
 
+import java.util.List;
+
 /**
  * Exports basic commit statistics.
  *
@@ -48,7 +52,11 @@
     Term a3 = arg3.dereference();
 
     PatchList pl = StoredValues.PATCH_LIST.get(engine);
-    if (!a1.unify(new IntegerTerm(pl.getPatches().size() - 1),engine.trail)) { //Account for /COMMIT_MSG.
+    // Account for magic files
+    if (!a1.unify(
+        new IntegerTerm(
+            pl.getPatches().size() - countMagicFiles(pl.getPatches())),
+        engine.trail)) {
       return engine.fail();
     }
     if (!a2.unify(new IntegerTerm(pl.getInsertions()),engine.trail)) {
@@ -59,4 +67,14 @@
     }
     return cont;
   }
+
+  private int countMagicFiles(List<PatchListEntry> entries) {
+    int count = 0;
+    for (PatchListEntry e : entries) {
+      if (Patch.isMagic(e.getNewName())) {
+        count++;
+      }
+    }
+    return count;
+  }
 }
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy
new file mode 100644
index 0000000..50c5fc3
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy
@@ -0,0 +1,39 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * .Abandoned template will determine the contents of the email related to a
+ * change being abandoned.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .Abandoned autoescape="strict" kind="text"}
+  {$fromName} has abandoned this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm
deleted file mode 100644
index accd3b8..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm
+++ /dev/null
@@ -1,46 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Abandoned.vm template will determine the contents of the email related
-## to a change being abandoned.   It is a ChangeEmail: see ChangeSubject.vm and
-## ChangeFooter.vm.
-##
-$fromName has abandoned this change.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($coverLetter)
-$coverLetter
-
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
new file mode 100644
index 0000000..e3b4613
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
@@ -0,0 +1,39 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .AbandonedHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} abandoned <strong>{$change.subject}</strong>.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    {call .Pre}{param content: $coverLetter /}{/call}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy
new file mode 100644
index 0000000..aa2b27d
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -0,0 +1,71 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .AddKey template will determine the contents of the email related to
+ * adding a new SSH or GPG key to an account.
+ * @param email
+ */
+{template .AddKey autoescape="strict" kind="text"}
+  One or more new {$email.keyType} keys have been added to Gerrit Code Review at
+  {sp}{$email.gerritHost}:
+
+  {\n}
+  {\n}
+
+  {if $email.sshKey}
+    {$email.sshKey}
+  {elseif $email.gpgKeys}
+    {$email.gpgKeys}
+  {/if}
+
+  {\n}
+  {\n}
+
+  If this is not expected, please contact your Gerrit Administrators
+  immediately.
+
+  {\n}
+  {\n}
+
+  You can also manage your {$email.keyType} keys by visiting
+  {\n}
+  {if $email.sshKey}
+    {$email.gerritUrl}#/settings/ssh-keys
+  {elseif $email.gpgKeys}
+    {$email.gerritUrl}#/settings/gpg-keys
+  {/if}
+  {\n}
+  {if $email.userNameEmail}
+    (while signed in as {$email.userNameEmail})
+  {else}
+    (while signed in as {$email.email})
+  {/if}
+
+  {\n}
+  {\n}
+
+  If clicking the link above does not work, copy and paste the URL in a new
+  browser window instead.
+
+  {\n}
+  {\n}
+
+  This is a send-only email address.  Replies to this message will not be read
+  or answered.
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.vm
deleted file mode 100644
index c60ce8b..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.vm
+++ /dev/null
@@ -1,61 +0,0 @@
-## Copyright (C) 2015 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The AddKey.vm template will determine the contents of the email
-## related to adding a new SSH or GPG key to an account.
-##
-One or more new ${email.keyType} keys have been added to Gerrit Code Review at ${email.gerritHost}:
-
-#if($email.sshKey)
-$email.sshKey
-#elseif($email.gpgKeys)
-$email.gpgKeys
-#end
-
-If this is not expected, please contact your Gerrit Administrators
-immediately.
-
-You can also manage your ${email.keyType} keys by visiting
-#if($email.sshKey)
-$email.gerritUrl#/settings/ssh-keys
-#elseif($email.gpgKeys)
-$email.gerritUrl#/settings/gpg-keys
-#end
-#if($email.userNameEmail)
-(while signed in as $email.userNameEmail)
-#else
-(while signed in as $email.email)
-#end
-
-If clicking the link above does not work, copy and paste the URL in a
-new browser window instead.
-
-This is a send-only email address.  Replies to this message will not
-be read or answered.
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
new file mode 100644
index 0000000..017fd6d
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -0,0 +1,66 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ */
+{template .AddKeyHtml autoescape="strict" kind="html"}
+  <p>
+    One or more new {$email.keyType} keys have been added to Gerrit Code Review
+    at {$email.gerritHost}:
+  </p>
+
+  {let $keyStyle kind="css"}
+    background: #f0f0f0;
+    border: 1px solid #ccc;
+    color: #555;
+    padding: 12px;
+    width: 400px;
+  {/let}
+
+  {if $email.sshKey}
+    <pre style="{$keyStyle}">{$email.sshKey}</pre>
+  {elseif $email.gpgKeys}
+    <pre style="{$keyStyle}">{$email.gpgKeys}</pre>
+  {/if}
+
+  <p>
+    If this is not expected, please contact your Gerrit Administrators
+    immediately.
+  </p>
+
+  <p>
+    You can also manage your {$email.keyType} keys by following{sp}
+    {if $email.sshKey}
+      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+    {elseif $email.gpgKeys}
+      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+    {/if}
+    {sp}
+    {if $email.userNameEmail}
+      (while signed in as {$email.userNameEmail})
+    {else}
+      (while signed in as {$email.email})
+    {/if}.
+  </p>
+
+  <p>
+    This is a send-only email address.  Replies to this message will not be read
+    or answered.
+  </p>
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
new file mode 100644
index 0000000..644008b7
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
@@ -0,0 +1,60 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .ChangeFooter template will determine the contents of the footer text
+ * that will be appended to ALL emails related to changes.
+ * @param branch
+ * @param ccEmails
+ * @param change
+ * @param changeId
+ * @param email
+ * @param messageClass
+ * @param patchSet
+ * @param projectName
+ * @param reviewerEmails
+ */
+{template .ChangeFooter autoescape="strict" kind="text"}
+  --{sp}
+  {\n}
+
+  {if $email.changeUrl}
+    To view, visit {$email.changeUrl}{\n}
+  {/if}
+
+  {if $email.settingsUrl}
+    To unsubscribe, visit {$email.settingsUrl}{\n}
+  {/if}
+
+  {if $email.changeUrl or $email.settingsUrl}
+    {\n}
+  {/if}
+
+  Gerrit-MessageType: {$messageClass}{\n}
+  Gerrit-Change-Id: {$changeId}{\n}
+  Gerrit-PatchSet: {$patchSet.patchSetId}{\n}
+  Gerrit-Project: {$projectName}{\n}
+  Gerrit-Branch: {$branch.shortName}{\n}
+  Gerrit-Owner: {$change.ownerEmail}{\n}
+  {foreach $reviewer in $reviewerEmails}
+    Gerrit-Reviewer: {$reviewer}{\n}
+  {/foreach}
+  {foreach $reviewer in $ccEmails}
+    Gerrit-CC: {$reviewer}{\n}
+  {/foreach}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.vm
deleted file mode 100644
index f1d3e90..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.vm
+++ /dev/null
@@ -1,52 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The ChangeFooter.vm template will determine the contents of the footer
-## text that will be appended to ALL emails related to changes.
-##
-#set ($SPACE = " ")
---$SPACE
-#if ($email.changeUrl)
-To view, visit $email.changeUrl
-#set ($notblank = 1)
-#end
-#if ($email.settingsUrl)
-To unsubscribe, visit $email.settingsUrl
-#set ($notblank = 1)
-#end
-#if ($notblank)
-
-#end
-Gerrit-MessageType: $messageClass
-Gerrit-Change-Id: $changeId
-Gerrit-PatchSet: $patchSet.patchSetId
-Gerrit-Project: $projectName
-Gerrit-Branch: $branch.shortName
-Gerrit-Owner: $email.getNameEmailFor($change.owner)
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
new file mode 100644
index 0000000..41ea1b6
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
@@ -0,0 +1,70 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param branch
+ * @param ccEmails
+ * @param change
+ * @param changeId
+ * @param email
+ * @param messageClass
+ * @param patchSet
+ * @param projectName
+ * @param reviewerEmails
+ */
+{template .ChangeFooterHtml autoescape="strict" kind="html"}
+  {let $footerStyle kind="css"}
+    display: none;
+  {/let}
+
+  {if $email.changeUrl or $email.settingsUrl}
+    <p>
+      {if $email.changeUrl}
+        To view, visit <a href="{$email.changeUrl}">this change</a>.
+      {/if}
+      {if $email.changeUrl and $email.settingsUrl}{sp}{/if}
+      {if $email.settingsUrl}
+        To unsubscribe, visit <a href="{$email.settingsUrl}">settings</a>.
+      {/if}
+    </p>
+  {/if}
+
+  <p style="{$footerStyle}">
+    Gerrit-MessageType: {$messageClass}<br/>
+    Gerrit-Change-Id: {$changeId}<br/>
+    Gerrit-PatchSet: {$patchSet.patchSetId}<br/>
+    Gerrit-Project: {$projectName}<br/>
+    Gerrit-Branch: {$branch.shortName}<br/>
+    Gerrit-Owner: {$change.ownerEmail}
+    {foreach $reviewer in $reviewerEmails}
+      Gerrit-Reviewer: {$reviewer}</br>
+    {/foreach}
+    {foreach $reviewer in $ccEmails}
+      Gerrit-CC: {$reviewer}</br>
+    {/foreach}
+  </p>
+
+  {if $email.changeUrl}
+    <div itemscope itemtype="http://schema.org/EmailMessage">
+      <div itemscope itemprop="action" itemtype="http://schema.org/ViewAction">
+        <link itemprop="url" href="{$email.changeUrl}"/>
+        <meta itemprop="name" content="View Change"/>
+      </div>
+    </div>
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy
new file mode 100644
index 0000000..98de6e7
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -0,0 +1,28 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .ChangeSubject template will determine the contents of the email subject
+ * line for ALL emails related to changes.
+ * @param branch
+ * @param change
+ * @param shortProjectName
+ */
+{template .ChangeSubject autoescape="strict" kind="text"}
+  Change in {$shortProjectName}[{$branch.shortName}]: {$change.shortSubject}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.vm
deleted file mode 100644
index 4fd9a23..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.vm
+++ /dev/null
@@ -1,42 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The ChangeSubject.vm template will determine the contents of the email
-## subject line for ALL emails related to changes.
-##
-## Optionally $change.originalSubject can be used for the first subject
-## in a change. This allows subject based email clients such as GMail
-## to thread comments together even if subsequent patch sets change the
-## first line of the commit message.
-##
-#macro(ellipsis $length $str)
-#if($str.length() > $length)#set($length = $length - 3)${str.substring(0,$length)}...#else$str#end
-#end
-Change in ${projectName.replaceAll('/.*/', '...')}[$branch.shortName]: #ellipsis(63, $change.subject)
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy
new file mode 100644
index 0000000..0e1f153
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy
@@ -0,0 +1,72 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Comment template will determine the contents of the email related to a
+ * user submitting comments on changes.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ * @param commentFiles
+ */
+{template .Comment autoescape="strict" kind="text"}
+  {$fromName} has posted comments on this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}{\n}
+    {\n}
+  {/if}
+
+  {foreach $group in $commentFiles}
+    {$group.link}{\n}
+    {$group.title}:{\n}
+    {\n}
+
+    {foreach $comment in $group.comments}
+      {if $comment.isRobotComment}
+        Robot Comment from {$comment.robotId} (run ID {$comment.robotRunId}):
+        {\n}
+      {/if}
+
+      {foreach $line in $comment.lines}
+        {if isFirst($line)}
+          {if $comment.startLine != 0}
+            {$comment.link}
+          {/if}{\n}
+          {$comment.linePrefix}
+        {else}
+          {$comment.linePrefixEmpty}
+        {/if}
+        {$line}{\n}
+      {/foreach}
+      {if $comment.parentMessage}
+        >{sp}{$comment.parentMessage}{\n}
+      {/if}
+      {$comment.message}{\n}
+      {\n}
+      {\n}
+    {/foreach}
+  {/foreach}
+  {\n}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm
deleted file mode 100644
index a442311..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm
+++ /dev/null
@@ -1,55 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Comment.vm template will determine the contents of the email related to
-## a user submitting comments on changes.  It is a ChangeEmail: see
-## ChangeSubject.vm, ChangeFooter.vm and CommentFooter.vm.
-##
-#if ($email.coverLetter || $email.hasInlineComments())
-$fromName has posted comments on this change.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($email.coverLetter)
-$email.coverLetter
-
-#end
-##
-## It is possible to increase the span of the quoted lines by using the line
-## count parameter when calling $email.getInlineComments as a function.
-##
-## Example: #if($email.hasInlineComments())$email.getInlineComments(5)#end
-##
-#if($email.hasInlineComments())$email.inlineComments#end
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
new file mode 100644
index 0000000..7ef58b7
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
@@ -0,0 +1,31 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .CommentFooter template will determine the contents of the footer text
+ * that will be appended to emails related to a user submitting comments on
+ * changes.
+ * @param commentFiles
+ */
+{template .CommentFooter autoescape="strict" kind="text"}
+  {if length($commentFiles) > 0}
+    Gerrit-HasComments: Yes
+  {else}
+    Gerrit-HasComments: No
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.vm
deleted file mode 100644
index e0832e6..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.vm
+++ /dev/null
@@ -1,40 +0,0 @@
-## Copyright (C) 2012 The Android Open Source Project
-##
-## Licensed under the Apache License, Version 2.0 (the "License");
-## you may not use this file except in compliance with the License.
-## You may obtain a copy of the License at
-##
-## http://www.apache.org/licenses/LICENSE-2.0
-##
-## Unless required by applicable law or agreed to in writing, software
-## distributed under the License is distributed on an "AS IS" BASIS,
-## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-## See the License for the specific language governing permissions and
-## limitations under the License.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The CommentFooter.vm template will determine the contents of the footer
-## text that will be appended to emails related to a user submitting comments
-## on changes.
-##
-## See ChangeSubject.vm and ChangeFooter.vm.
-#if($email.hasInlineComments())
-Gerrit-HasComments: Yes
-#else
-Gerrit-HasComments: No
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
new file mode 100644
index 0000000..84cace2
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
@@ -0,0 +1,34 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param commentFiles
+ */
+{template .CommentFooterHtml autoescape="strict" kind="html"}
+  {let $footerStyle kind="css"}
+    display: none;
+  {/let}
+
+  <p style="{$footerStyle}">
+    {if length($commentFiles) > 0}
+      Gerrit-HasComments: Yes
+    {else}
+      Gerrit-HasComments: No
+    {/if}
+  </p>
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
new file mode 100644
index 0000000..6dc37b8
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -0,0 +1,127 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param commentFiles
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .CommentHtml autoescape="strict" kind="html"}
+  {let $commentHeaderStyle kind="css"}
+    margin-bottom: 4px;
+  {/let}
+
+  {let $blockquoteStyle kind="css"}
+    border-left: 1px solid #aaa;
+    margin: 10px 0;
+    padding: 0 10px;
+  {/let}
+
+  {let $messageStyle kind="css"}
+    white-space: pre-wrap;
+  {/let}
+
+  {let $ulStyle kind="css"}
+    list-style: none;
+    padding-left: 20px;
+  {/let}
+
+  <p>
+    {$fromName} posted comments on <strong>{$change.subject}</strong>.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    <div style="white-space:pre-wrap">{$coverLetter}</div>
+  {/if}
+
+  <ul style="{$ulStyle}">
+    {foreach $group in $commentFiles}
+      <li>
+        <p>
+          <a href="{$group.link}">{$group.title}:</a>
+        </p>
+
+        <ul style="{$ulStyle}">
+          {foreach $comment in $group.comments}
+            <li>
+              {if $comment.isRobotComment}
+                <p style="{$commentHeaderStyle}">
+                  Robot Comment from{sp}
+                  {if $comment.robotUrl}<a href="{$comment.robotUrl}">{/if}
+                  {$comment.robotId}
+                  {if $comment.robotUrl}</a>{/if}{sp}
+                  (run ID {$comment.robotRunId}):
+                </p>
+              {/if}
+
+              <p style="{$commentHeaderStyle}">
+                {if length($comment.lines) > 0}
+                  <a href="{$comment.link}">
+                    {if $comment.startLine == 0}
+                      Patch Set #{$group.patchSetId}:
+                    {else}
+                      Patch Set #{$group.patchSetId},{sp}
+                      Line {$comment.startLine}:
+                    {/if}
+                  </a>{sp}
+                {/if}
+                {if length($comment.lines) == 1}
+                  <code style="font-family:monospace,monospace">
+                    {$comment.lines[0]}
+                  </code>
+                {/if}
+              </p>
+
+              {if length($comment.lines) > 1}
+                <p>
+                  <blockquote style="{$blockquoteStyle}">
+                    {call .Pre}{param content kind="html"}
+                      {foreach $line in $comment.lines}
+                        {$line}{\n}
+                      {/foreach}
+                    {/param}{/call}
+                  </blockquote>
+                </p>
+              {/if}
+
+              {if $comment.parentMessage}
+                <p>
+                  <blockquote style="{$blockquoteStyle}">
+                    {$comment.parentMessage}
+                  </blockquote>
+                </p>
+              {/if}
+
+              <p style="{$messageStyle}">
+                {$comment.message}
+              </p>
+            </li>
+          {/foreach}
+        </ul>
+      </li>
+    {/foreach}
+  </ul>
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
new file mode 100644
index 0000000..888ee4b
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
@@ -0,0 +1,44 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .DeleteReviewer template will determine the contents of the email related
+ * to removal of a reviewer (and the reviewer's votes) from reviews.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .DeleteReviewer autoescape="strict" kind="text"}
+  {$fromName} has removed{sp}
+  {foreach $reviewerName in $email.reviewerNames}
+    {if not isFirst($reviewerName)},{sp}{/if}
+    {$reviewerName}
+  {/foreach}{sp}
+  from this change.{sp}
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm
deleted file mode 100644
index 635b716..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm
+++ /dev/null
@@ -1,47 +0,0 @@
-## Copyright (C) 2016 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The DeleteReviewer.vm template will determine the contents of the email
-## related to removal of a reviewer (and the reviewer's votes) from reviews.
-## It is a ChangeEmail: see ChangeSubject.vm and ChangeFooter.vm.
-##
-$fromName has removed $email.joinStrings($email.reviewerNames, ', ') from #**
-*#this change.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($email.coverLetter)
-$email.coverLetter
-
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
new file mode 100644
index 0000000..6817fbd
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
@@ -0,0 +1,44 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .DeleteReviewerHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} removed{sp}
+    {foreach $reviewerName in $email.reviewerNames}
+      {if not isFirst($reviewerName)},{sp}{/if}
+      {$reviewerName}
+    {/foreach}{sp}
+    from <strong>{$change.subject}</strong>.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    {call .Pre}{param content: $coverLetter /}{/call}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy
new file mode 100644
index 0000000..b249ded
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy
@@ -0,0 +1,37 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .DeleteVote template will determine the contents of the email related
+ * to removing votes on changes.
+ * @param change
+ * @param coverLetter
+ * @param fromName
+ */
+{template .DeleteVote autoescape="strict" kind="text"}
+  {$fromName} has removed a vote on this change.{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.vm
deleted file mode 100644
index 294063e..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.vm
+++ /dev/null
@@ -1,44 +0,0 @@
-## Copyright (C) 2016 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The DeleteVote.vm template will determine the contents of the email related
-## to removing votes on changes.  It is a ChangeEmail: see ChangeSubject.vm
-## and ChangeFooter.vm.
-##
-$fromName has removed a vote on this change.
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($coverLetter)
-$coverLetter
-
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
new file mode 100644
index 0000000..bfcd8d5
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
@@ -0,0 +1,39 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .DeleteVoteHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} removed a vote from <strong>{$change.subject}</strong>.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    {call .Pre}{param content: $coverLetter /}{/call}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
new file mode 100644
index 0000000..6467e95
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
@@ -0,0 +1,25 @@
+/**
+ * Copyright (C) 2016 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.
+*/
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Footer template will determine the contents of the footer text
+ * appended to the end of all outgoing emails after the ChangeFooter and
+ * CommentFooter.
+ */
+{template .Footer}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.vm
deleted file mode 100644
index 28f29fd..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.vm
+++ /dev/null
@@ -1,33 +0,0 @@
-## Copyright (C) 2013 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Footer.vm template will determine the contents of the footer text
-## appended to the end of all outgoing emails after the ChangeFooter and
-## CommentFooter.
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
new file mode 100644
index 0000000..9befa51
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
@@ -0,0 +1,20 @@
+/**
+ * Copyright (C) 2016 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.
+*/
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .FooterHtml autoescape="strict" kind="html"}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
new file mode 100644
index 0000000..fdc3fee
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
@@ -0,0 +1,20 @@
+/**
+ * Copyright (C) 2016 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.
+*/
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .HeaderHtml autoescape="strict" kind="html"}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy
new file mode 100644
index 0000000..d483264
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy
@@ -0,0 +1,42 @@
+
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Merged template will determine the contents of the email related to
+ * a change successfully merged to the head.
+ * @param change
+ * @param email
+ * @param fromName
+ */
+{template .Merged autoescape="strict" kind="text"}
+  {$fromName} has submitted this change and it was merged.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {\n}
+  {$email.changeDetail}
+  {$email.approvals}
+  {if $email.includeDiff}
+    {\n}
+    {\n}
+    {$email.unifiedDiff}
+    {\n}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm
deleted file mode 100644
index 3e49e92..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm
+++ /dev/null
@@ -1,47 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Merged.vm template will determine the contents of the email related to
-## a change successfully merged to the head.  It is a ChangeEmail: see
-## ChangeSubject.vm and ChangeFooter.vm.
-##
-$fromName has submitted this change and it was merged.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-$email.changeDetail$email.approvals
-
-#if($email.includeDiff)
-$email.UnifiedDiff
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
new file mode 100644
index 0000000..257f522
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -0,0 +1,42 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param email
+ * @param fromName
+ */
+{template .MergedHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} merged <strong>{$change.subject}</strong>.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {call .Pre}{param content: $email.changeDetail /}{/call}
+
+  {call .Pre}{param content: $email.approvals /}{/call}
+
+  {if $email.includeDiff}
+    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy
new file mode 100644
index 0000000..296f625
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -0,0 +1,81 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .NewChange template will determine the contents of the email related to a
+ * user submitting a new change for review.
+ * @param change
+ * @param email
+ * @param fromName
+ * @param patchSet
+ * @param projectName
+ */
+{template .NewChange autoescape="strict" kind="text"}
+  {if $email.reviewerNames}
+    Hello{sp}
+    {foreach $reviewerName in $email.reviewerNames}
+      {if not isFirst($reviewerName)},{sp}{/if}
+      {$reviewerName}
+    {/foreach},
+
+    {\n}
+    {\n}
+
+    I'd like you to do a code review.
+
+    {if $email.changeUrl}
+      {sp}Please visit
+
+      {\n}
+      {\n}
+
+      {sp}{sp}{sp}{sp}{$email.changeUrl}
+
+      {\n}
+      {\n}
+
+      to review the following change.
+    {/if}
+  {else}
+    {$fromName} has uploaded a new change for review.
+    {if $email.changeUrl} ( {$email.changeUrl}{/if}
+  {/if}{\n}
+
+  {\n}
+  {\n}
+
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+
+  {\n}
+
+  {$email.changeDetail}{\n}
+
+  {if $email.sshHost}
+    {\n}
+    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+        {sp}{$patchSet.refName}
+    {\n}
+  {/if}
+
+  {if $email.includeDiff}
+    {\n}
+    {$email.unifiedDiff}
+    {\n}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm
deleted file mode 100644
index 8b66e81..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm
+++ /dev/null
@@ -1,60 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The NewChange.vm template will determine the contents of the email related
-## to a user submitting a new change for review. It is a ChangeEmail: see
-## ChangeSubject.vm and ChangeFooter.vm.
-##
-#if($email.reviewerNames)
-Hello $email.joinStrings($email.reviewerNames, ', '),
-
-I'd like you to do a code review.#if($email.changeUrl)  Please visit
-
-    $email.changeUrl
-
-to review the following change.
-#end
-#else
-$fromName has uploaded a new change for review.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-#end
-
-
-Change subject: $change.subject
-......................................................................
-
-$email.changeDetail
-#if($email.sshHost)
-  git pull ssh://$email.sshHost/$projectName $patchSet.refName
-#end
-#if($email.includeDiff)
-
-$email.UnifiedDiff
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
new file mode 100644
index 0000000..63d3462
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -0,0 +1,60 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param email
+ * @param fromName
+ * @param patchSet
+ * @param projectName
+ */
+{template .NewChangeHtml autoescape="strict" kind="html"}
+  <p>
+    {if $email.reviewerNames}
+      {$fromName} would like{sp}
+      {foreach $reviewerName in $email.reviewerNames}
+        {if not isFirst($reviewerName)}
+          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
+        {/if}
+        {$reviewerName}
+      {/foreach}{sp}
+      to review <strong>{$change.subject}</strong>.
+    {else}
+      {$fromName} uploaded <strong>{$change.subject}</strong> for review.
+    {/if}
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {call .Pre}{param content: $email.changeDetail /}{/call}
+
+  {if $email.sshHost}
+    {call .Pre}{param content kind="html"}
+      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+          {sp}{$patchSet.refName}
+    {/param}{/call}
+  {/if}
+
+  {if $email.includeDiff}
+    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
new file mode 100644
index 0000000..88cd8d0
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
@@ -0,0 +1,43 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/*
+ * Private templates that cannot be overridden.
+ */
+
+/**
+ * Private template to generate "View Change" buttons.
+ * @param email
+ */
+{template .ViewChangeButton private="true" autoescape="strict" kind="html"}
+  <a href="{$email.changeUrl}">View Change</a>
+{/template}
+
+/**
+ * Private template to render PRE block with consistent font-sizing.
+ * @param content
+ */
+{template .Pre private="true" autoescape="strict" kind="html"}
+  {let $preStyle kind="css"}
+    font-family: monospace,monospace; // Use this to avoid browsers scaling down
+                                      // monospace text.
+    white-space: pre-wrap;
+  {/let}
+  <pre style="{$preStyle}">{$content}</pre>
+{/template}
+
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
new file mode 100644
index 0000000..2b30ae6
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -0,0 +1,54 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .RegisterNewEmail template will determine the contents of the email
+ * related to registering new email accounts.
+ * @param email
+ */
+{template .RegisterNewEmail autoescape="strict" kind="text"}
+  Welcome to Gerrit Code Review at {$email.gerritHost}.{\n}
+
+  {\n}
+
+  To add a verified email address to your user account, please{\n}
+  click on the following link
+  {if $email.userNameEmail}
+    {sp}while signed in as {$email.userNameEmail}
+  {/if}:{\n}
+
+  {\n}
+
+  {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}{\n}
+
+  {\n}
+
+  If you have received this mail in error, you do not need to take any{\n}
+  action to cancel the account.  The address will not be activated, and{\n}
+  you will not receive any further emails.{\n}
+
+  {\n}
+
+  If clicking the link above does not work, copy and paste the URL in a{\n}
+  new browser window instead.{\n}
+
+  {\n}
+
+  This is a send-only email address.  Replies to this message will not{\n}
+  be read or answered.{\n}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.vm
deleted file mode 100644
index 7e095fb..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.vm
+++ /dev/null
@@ -1,49 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The RegisterNewEmail.vm template will determine the contents of the email
-## related to registering new email accounts.
-##
-Welcome to Gerrit Code Review at ${email.gerritHost}.
-
-To add a verified email address to your user account, please
-click on the following link#if($email.userNameEmail) while signed in as $email.userNameEmail#end:
-
-$email.gerritUrl#/VE/$email.emailRegistrationToken
-
-If you have received this mail in error, you do not need to take any
-action to cancel the account.  The address will not be activated, and
-you will not receive any further emails.
-
-If clicking the link above does not work, copy and paste the URL in a
-new browser window instead.
-
-This is a send-only email address.  Replies to this message will not
-be read or answered.
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
new file mode 100644
index 0000000..2236725
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -0,0 +1,59 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .ReplacePatchSet template will determine the contents of the email
+ * related to a user submitting a new patchset for a change.
+ * @param change
+ * @param email
+ * @param fromName
+ * @param patchSet
+ * @param projectName
+ */
+{template .ReplacePatchSet autoescape="strict" kind="text"}
+  {if $email.reviewerNames}
+    Hello{sp}
+    {foreach $reviewerName in $email.reviewerNames}
+      {$reviewerName},{sp}
+    {/foreach}{\n}
+    {\n}
+    I'd like you to reexamine a change.
+    {if $email.changeUrl}
+      {sp}Please visit
+      {\n}
+      {\n}
+      {sp}{sp}{sp}{sp}{$email.changeUrl}
+      {\n}
+      {\n}
+      to look at the new patch set (#{$patchSet.patchSetId}).
+    {/if}
+  {else}
+    {$fromName} has uploaded a new patch set (#{$patchSet.patchSetId}).
+    {if $email.changeUrl} ( {$email.changeUrl}{/if}
+  {/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {\n}
+  {$email.changeDetail}{\n}
+  {if $email.sshHost}
+    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
+        {$patchSet.refName}
+    {\n}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm
deleted file mode 100644
index e45bf30..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm
+++ /dev/null
@@ -1,56 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The ReplacePatchSet.vm template will determine the contents of the email
-## related to a user submitting a new patchset for a change.  It is a
-## ChangeEmail: see ChangeSubject.vm and ChangeFooter.vm.
-##
-#if($email.reviewerNames)
-Hello $email.joinStrings($email.reviewerNames, ', '),
-
-I'd like you to reexamine a change.#if($email.changeUrl)  Please visit
-
-    $email.changeUrl
-
-to look at the new patch set (#$patchSet.patchSetId).
-#end
-#else
-$fromName has uploaded a new patch set (#$patchSet.patchSetId).#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-#end
-
-Change subject: $change.subject
-......................................................................
-
-$email.changeDetail
-#if($email.sshHost)
-  git pull ssh://$email.sshHost/$projectName $patchSet.refName
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
new file mode 100644
index 0000000..0163732
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -0,0 +1,46 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param email
+ * @param fromName
+ * @param patchSet
+ * @param projectName
+ */
+{template .ReplacePatchSetHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} uploaded patch set #{$patchSet.patchSetId} to{sp}
+    <strong>{$change.subject}</strong>.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {call .Pre}{param content: $email.changeDetail /}{/call}
+
+  {if $email.sshHost}
+    {call .Pre}{param content kind="html"}
+      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
+          {$patchSet.refName}
+    {/param}{/call}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy
new file mode 100644
index 0000000..14ae0f3
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy
@@ -0,0 +1,39 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Restored template will determine the contents of the email related to a
+ * change being restored.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .Restored autoescape="strict" kind="text"}
+  {$fromName} has restored this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm
deleted file mode 100644
index 31e1c69..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm
+++ /dev/null
@@ -1,46 +0,0 @@
-## Copyright (C) 2011 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Restored.vm template will determine the contents of the email related
-## to a change being restored.   It is a ChangeEmail: see ChangeSubject.vm and
-## ChangeFooter.vm.
-##
-$fromName has restored this change.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($coverLetter)
-$coverLetter
-
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
new file mode 100644
index 0000000..525c6d3
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
@@ -0,0 +1,39 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .RestoredHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} restored <strong>{$change.subject}</strong>.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    {call .Pre}{param content: $coverLetter /}{/call}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy
new file mode 100644
index 0000000..7f74df9
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy
@@ -0,0 +1,39 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Reverted template will determine the contents of the email related
+ * to a change being reverted.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .Reverted autoescape="strict" kind="text"}
+  {$fromName} has reverted this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm
deleted file mode 100644
index 1e9e251..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm
+++ /dev/null
@@ -1,46 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Reverted.vm template will determine the contents of the email related
-## to a change being reverted.   It is a ChangeEmail: see ChangeSubject.vm and
-## ChangeFooter.vm.
-##
-$fromName has reverted this change.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($coverLetter)
-$coverLetter
-
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
new file mode 100644
index 0000000..9770f09
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
@@ -0,0 +1,39 @@
+/**
+ * Copyright (C) 2016 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .RevertedHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} reverted the change: <strong>{$change.subject}</strong>.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    {call .Pre}{param content: $coverLetter /}{/call}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
index d51547c..5a937b6 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -97,7 +97,7 @@
 in = text/x-properties
 ini = text/x-properties
 intr = text/x-dylan
-jade = text/x-jade
+jade = text/x-pug
 java = text/x-java
 jl = text/x-julia
 jruby = text/x-ruby
@@ -163,6 +163,7 @@
 ps1 = application/x-powershell
 psd1 = application/x-powershell
 psm1 = application/x-powershell
+pug = text/x-pug
 py = text/x-python
 pyw = text/x-python
 pyx = text/x-cython
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
new file mode 100644
index 0000000..d76c239
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+#
+# Copyright (C) 2016 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.
+#
+# A sceleton script to demonstrate how to use the preview_submit REST API call.
+#
+#
+
+if test -z $server
+then
+        echo "The variable 'server' needs to point to your Gerrit instance"
+        exit 1
+fi
+
+if test -z $changeId
+then
+        echo "The variable 'changeId' must contain a valid change Id"
+        exit 1
+fi
+
+if test -z $gerrituser
+then
+        echo "The variable 'gerrituser' must contain a user/password"
+        exit 1
+fi
+
+curl --digest -u $gerrituser -w '%{http_code}' -o preview \
+    $server/a/changes/$changeId/revisions/current/preview_submit?format=tgz >http_code
+if ! grep 200 http_code >/dev/null
+then
+        # error out:
+        echo "Error previewing submit $changeId due to:"
+        cat preview
+        echo
+else
+        # valid tgz file, extract and obtain a bundle for each project
+        mkdir tmp-bundles
+        (cd tmp-bundles && tar -zxf ../preview)
+        for project in $(cd tmp-bundles && find -type f)
+        do
+                # Projects may contain slashes, so create the required
+                # directory structure
+                mkdir -p $(dirname $project)
+                # $project is in the format of "./path/name/project.git"
+                # remove the leading ./
+                proj=${project:-./}
+                git clone $server/$proj $proj
+
+                # First some nice output:
+                echo "Verify that the bundle is good:"
+                GIT_WORK_TREE=$proj GIT_DIR=$proj/.git \
+                        git bundle verify tmp-bundles/$proj
+                echo "Checking that the bundle only contains one branch..."
+                if test \
+                    "$(GIT_WORK_TREE=$proj GIT_DIR=$proj/.git \
+                    git bundle list-heads tmp-bundles/$proj |wc -l)" != 1
+                then
+                        echo "Submitting $changeId would affect the project"
+                        echo "$proj"
+                        echo "on multiple branches:"
+                        git bundle list-heads
+                        echo "This script does not demonstrate this use case."
+                        exit 1
+                fi
+                # find the target branch:
+                branch=$(GIT_WORK_TREE=$proj GIT_DIR=$proj/.git \
+                    git bundle list-heads tmp-bundles/$proj | awk '{print $2}')
+                echo "found branch $branch"
+                echo "fetch the bundle into the repository"
+                GIT_WORK_TREE=$proj GIT_DIR=$proj/.git \
+                        git fetch tmp-bundles/$proj $branch
+                echo "and checkout the state"
+                git -C $proj checkout FETCH_HEAD
+        done
+        echo "Now run a test for all of: $(cd tmp-bundles && find -type f)"
+fi
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java
index f5849c1..2a86401 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 
@@ -24,6 +23,7 @@
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 
 public class AuthorizedKeysTest {
   private static final String KEY1 =
@@ -168,7 +168,7 @@
    * @return the expected line for this key in the authorized_keys file
    */
   private static String addDeletedKey(List<Optional<AccountSshKey>> keys) {
-    keys.add(Optional.<AccountSshKey> absent());
+    keys.add(Optional.empty());
     return AuthorizedKeys.DELETED_KEY_COMMENT + "\n";
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
index bf36738..cfa1f5e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -58,6 +58,10 @@
     configureDefaultSubmitType("*", SubmitType.REBASE_IF_NECESSARY);
     assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
         .isEqualTo(SubmitType.REBASE_IF_NECESSARY);
+
+    configureDefaultSubmitType("*", SubmitType.REBASE_ALWAYS);
+    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+        .isEqualTo(SubmitType.REBASE_ALWAYS);
   }
 
   @Test
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupListTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupListTest.java
index fde86a5..3b21444 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupListTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupListTest.java
@@ -27,6 +27,7 @@
 
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -37,7 +38,7 @@
 import java.util.Set;
 
 public class GroupListTest {
-
+  private static final Project.NameKey PROJECT = new Project.NameKey("project");
   private static final String TEXT =
       "# UUID                                  \tGroup Name\n" + "#\n"
           + "d96b998f8a66ff433af50befb975d0e2bb6e0999\tNon-Interactive Users\n"
@@ -49,7 +50,7 @@
   public void setup() throws IOException {
     ValidationError.Sink sink = createNiceMock(ValidationError.Sink.class);
     replay(sink);
-    groupList = GroupList.parse(TEXT, sink);
+    groupList = GroupList.parse(PROJECT, TEXT, sink);
   }
 
   @Test
@@ -103,7 +104,7 @@
     sink.error(anyObject(ValidationError.class));
     expectLastCall().times(2);
     replay(sink);
-    groupList = GroupList.parse(TEXT.replace("\t", "    "), sink);
+    groupList = GroupList.parse(PROJECT, TEXT.replace("\t", "    "), sink);
     verify(sink);
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 839d349..7871437 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -15,10 +15,14 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Table;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -70,4 +74,63 @@
     assertThat(ChangeField.parseReviewerFieldValues(values))
         .isEqualTo(reviewers);
   }
+
+  @Test
+  public void formatSubmitRecordValues() {
+    assertThat(
+            ChangeField.formatSubmitRecordValues(
+                ImmutableList.of(
+                    record(
+                        SubmitRecord.Status.OK,
+                        label(SubmitRecord.Label.Status.MAY, "Label-1", null),
+                        label(SubmitRecord.Label.Status.OK, "Label-2", 1))),
+                new Account.Id(1)))
+        .containsExactly(
+            "OK",
+            "MAY,label-1",
+            "OK,label-2",
+            "OK,label-2,0",
+            "OK,label-2,1");
+  }
+
+  @Test
+  public void storedSubmitRecords() {
+    assertStoredRecordRoundTrip(record(SubmitRecord.Status.CLOSED));
+    assertStoredRecordRoundTrip(
+        record(
+            SubmitRecord.Status.OK,
+            label(SubmitRecord.Label.Status.MAY, "Label-1", null),
+            label(SubmitRecord.Label.Status.OK, "Label-2", 1)));
+  }
+
+  private static SubmitRecord record(SubmitRecord.Status status,
+      SubmitRecord.Label... labels) {
+    SubmitRecord r = new SubmitRecord();
+    r.status = status;
+    if (labels.length > 0) {
+      r.labels = ImmutableList.copyOf(labels);
+    }
+    return r;
+  }
+
+  private static SubmitRecord.Label label(SubmitRecord.Label.Status status,
+      String label, Integer appliedBy) {
+    SubmitRecord.Label l = new SubmitRecord.Label();
+    l.status = status;
+    l.label = label;
+    if (appliedBy != null) {
+      l.appliedBy = new Account.Id(appliedBy);
+    }
+    return l;
+  }
+
+  private static void assertStoredRecordRoundTrip(SubmitRecord... records) {
+    List<SubmitRecord> recordList = ImmutableList.copyOf(records);
+    List<String> stored = ChangeField.storedSubmitRecords(recordList).stream()
+        .map(s -> new String(s, UTF_8))
+        .collect(toList());
+    assertThat(ChangeField.parseSubmitRecords(stored))
+        .named("JSON %s" + stored)
+        .isEqualTo(recordList);
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java
index 4f2c776..4d7bf08 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java
@@ -17,6 +17,8 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
 
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+
 import org.junit.Test;
 
 import java.io.BufferedReader;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/FromAddressGeneratorProviderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
similarity index 64%
rename from gerrit-server/src/test/java/com/google/gerrit/server/mail/FromAddressGeneratorProviderTest.java
rename to gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index 11f1d54..01580c3 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/FromAddressGeneratorProviderTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.mail;
+package com.google.gerrit.server.mail.send;
 
 import static com.google.common.truth.Truth.assertThat;
 import static org.easymock.EasyMock.createStrictMock;
@@ -29,14 +29,17 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.mail.Address;
 
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Before;
 import org.junit.Test;
 
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Set;
 
 public class FromAddressGeneratorProviderTest {
@@ -60,6 +63,10 @@
     config.setString("sendemail", null, "from", newFrom);
   }
 
+  private void setDomains(List<String> domains) {
+    config.setStringList("sendemail", null, "allowedDomain", domains);
+  }
+
   @Test
   public void testDefaultIsMIXED() {
     assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
@@ -88,8 +95,8 @@
     replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(name);
-    assertThat(r.email).isEqualTo(email);
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
     verify(accountCache);
   }
 
@@ -103,8 +110,8 @@
     replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.name).isNull();
-    assertThat(r.email).isEqualTo(email);
+    assertThat(r.getName()).isNull();
+    assertThat(r.getEmail()).isEqualTo(email);
     verify(accountCache);
   }
 
@@ -118,8 +125,8 @@
     replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(name);
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
     verify(accountCache);
   }
 
@@ -129,8 +136,90 @@
     replay(accountCache);
     final Address r = create().from(null);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(ident.getName());
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void testUSERAllowDomain() {
+    setFrom("USER");
+    setDomains(Arrays.asList("*.example.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void testUSERNoAllowDomain() {
+    setFrom("USER");
+    setDomains(Arrays.asList("example.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void testUSERAllowDomainTwice() {
+    setFrom("USER");
+    setDomains(Arrays.asList("example.com"));
+    setDomains(Arrays.asList("test.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void testUSERAllowDomainTwiceReverse() {
+    setFrom("USER");
+    setDomains(Arrays.asList("test.com"));
+    setDomains(Arrays.asList("example.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void testUSERAllowTwoDomains() {
+    setFrom("USER");
+    setDomains(Arrays.asList("example.com", "test.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
     verify(accountCache);
   }
 
@@ -157,8 +246,8 @@
     replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(ident.getName());
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
     verify(accountCache);
   }
 
@@ -168,8 +257,8 @@
     replay(accountCache);
     final Address r = create().from(null);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(ident.getName());
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
     verify(accountCache);
   }
 
@@ -196,8 +285,8 @@
     replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(name + " (Code Review)");
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
     verify(accountCache);
   }
 
@@ -211,8 +300,8 @@
     replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo("Anonymous Coward (Code Review)");
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
+    assertThat(r.getName()).isEqualTo("Anonymous Coward (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
     verify(accountCache);
   }
 
@@ -226,8 +315,8 @@
     replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(name + " (Code Review)");
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
     verify(accountCache);
   }
 
@@ -237,8 +326,8 @@
     replay(accountCache);
     final Address r = create().from(null);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(ident.getName());
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
     verify(accountCache);
   }
 
@@ -253,8 +342,8 @@
     replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo("A " + name + " B");
-    assertThat(r.email).isEqualTo("my.server@email.address");
+    assertThat(r.getName()).isEqualTo("A " + name + " B");
+    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
     verify(accountCache);
   }
 
@@ -268,8 +357,8 @@
     replay(accountCache);
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo("A Anonymous Coward B");
-    assertThat(r.email).isEqualTo("my.server@email.address");
+    assertThat(r.getName()).isEqualTo("A Anonymous Coward B");
+    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
     verify(accountCache);
   }
 
@@ -280,8 +369,8 @@
     replay(accountCache);
     final Address r = create().from(null);
     assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(ident.getName());
-    assertThat(r.email).isEqualTo("my.server@email.address");
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
     verify(accountCache);
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index fabb53d..0a98c40 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -25,12 +25,10 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -54,6 +52,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeAccountCache;
 import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
@@ -76,12 +75,31 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.runner.RunWith;
 
 import java.sql.Timestamp;
 import java.util.TimeZone;
 
 @Ignore
+@RunWith(ConfigSuite.class)
 public abstract class AbstractChangeNotesTest extends GerritBaseTests {
+  @ConfigSuite.Default
+  public static Config changeNotesLegacy() {
+    Config cfg = new Config();
+    cfg.setBoolean("notedb", null, "writeJson", false);
+    return cfg;
+  }
+
+  @ConfigSuite.Config
+  public static Config changeNotesJson() {
+    Config cfg = new Config();
+    cfg.setBoolean("notedb", null, "writeJson", true);
+    return cfg;
+  }
+
+  @ConfigSuite.Parameter
+  public Config testConfig;
+
   private static final TimeZone TZ =
       TimeZone.getTimeZone("America/Los_Angeles");
 
@@ -110,12 +128,13 @@
   protected AllUsersName allUsers;
 
   @Inject
-  protected ChangeNoteUtil noteUtil;
-
-  @Inject
   protected AbstractChangeNotes.Args args;
 
-  private Injector injector;
+  @Inject
+  @GerritServerId
+  private String serverId;
+
+  protected Injector injector;
   private String systemTimeZone;
 
   @Before
@@ -143,9 +162,8 @@
     injector = Guice.createInjector(new FactoryModule() {
       @Override
       public void configure() {
-        Config cfg = new Config();
         install(new GitModule());
-        install(NoteDbModule.forTest(cfg));
+        install(NoteDbModule.forTest(testConfig));
         bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
         bind(String.class).annotatedWith(GerritServerId.class)
             .toInstance("gerrit");
@@ -155,7 +173,7 @@
         bind(CapabilityControl.Factory.class)
             .toProvider(Providers.<CapabilityControl.Factory> of(null));
         bind(Config.class).annotatedWith(GerritServerConfig.class)
-            .toInstance(cfg);
+            .toInstance(testConfig);
         bind(String.class).annotatedWith(AnonymousCowardName.class)
             .toProvider(AnonymousCowardNameProvider.class);
         bind(String.class).annotatedWith(CanonicalWebUrl.class)
@@ -234,30 +252,22 @@
     return label;
   }
 
-  protected PatchLineComment newPublishedComment(PatchSet.Id psId,
-      String filename, String UUID, CommentRange range, int line,
-      IdentifiedUser commenter, String parentUUID, Timestamp t,
-      String message, short side, String commitSHA1) {
-    return newComment(psId, filename, UUID, range, line, commenter,
-        parentUUID, t, message, side, commitSHA1,
-        PatchLineComment.Status.PUBLISHED);
-  }
+  protected Comment newComment(PatchSet.Id psId, String filename, String UUID,
+      CommentRange range, int line, IdentifiedUser commenter, String parentUUID,
+      Timestamp t, String message, short side, String commitSHA1) {
+    Comment c = new Comment(
+        new Comment.Key(UUID, filename, psId.get()),
+        commenter.getAccountId(),
+        t,
+        side,
+        message,
+        serverId);
+    c.lineNbr = line;
+    c.parentUuid = parentUUID;
+    c.revId = commitSHA1;
+    c.setRange(range);
+    return c;
 
-  protected PatchLineComment newComment(PatchSet.Id psId,
-      String filename, String UUID, CommentRange range, int line,
-      IdentifiedUser commenter, String parentUUID, Timestamp t,
-      String message, short side, String commitSHA1,
-      PatchLineComment.Status status) {
-    PatchLineComment comment = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(psId, filename), UUID),
-        line, commenter.getAccountId(), parentUUID, t);
-    comment.setSide(side);
-    comment.setMessage(message);
-    comment.setRange(range);
-    comment.setRevId(new RevId(commitSHA1));
-    comment.setStatus(status);
-    return comment;
   }
 
   protected static Timestamp truncate(Timestamp ts) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
index c093b75..faa3105 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.gwtorm.client.KeyUtil;
@@ -327,6 +328,10 @@
       throws Exception {
     Change c1 = TestChanges.newChange(
         new Project.NameKey("project"), new Account.Id(100));
+    PatchSet ps = new PatchSet(c1.currentPatchSetId());
+    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps.setUploader(accountId);
+    ps.setCreatedOn(TimeUtil.nowTs());
     PatchSetApproval a = new PatchSetApproval(
         new PatchSetApproval.Key(
             c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
@@ -337,16 +342,16 @@
     c2.setLastUpdatedOn(a.getGranted());
 
     // Both ReviewDb, exact match required.
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(),
+    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(ps),
         approvals(a), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(),
+    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(ps),
         approvals(a), comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "effective last updated time differs for Change.Id " + c1.getId() + ":"
-            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}");
+            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:12.0}");
 
     // NoteDb allows latest timestamp from all entities in bundle.
-    b2 = new ChangeBundle(c2, messages(), patchSets(),
+    b2 = new ChangeBundle(c2, messages(), patchSets(ps),
         approvals(a), comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
   }
@@ -355,6 +360,10 @@
   public void diffChangesIgnoresChangeTimestampIfAnyOtherEntitiesExist() {
     Change c1 = TestChanges.newChange(
         new Project.NameKey("project"), new Account.Id(100));
+    PatchSet ps = new PatchSet(c1.currentPatchSetId());
+    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps.setUploader(accountId);
+    ps.setCreatedOn(TimeUtil.nowTs());
     PatchSetApproval a = new PatchSetApproval(
         new PatchSetApproval.Key(
             c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
@@ -367,9 +376,9 @@
 
     // ReviewDb has later lastUpdatedOn timestamp than NoteDb, allowed since
     // NoteDb matches the latest timestamp of a non-Change entity.
-    ChangeBundle b1 = new ChangeBundle(c2, messages(), patchSets(),
+    ChangeBundle b1 = new ChangeBundle(c2, messages(), patchSets(ps),
         approvals(a), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c1, messages(), patchSets(),
+    ChangeBundle b2 = new ChangeBundle(c1, messages(), patchSets(ps),
         approvals(a), comments(), reviewers(), NOTE_DB);
     assertThat(b1.getChange().getLastUpdatedOn())
         .isGreaterThan(b2.getChange().getLastUpdatedOn());
@@ -383,7 +392,7 @@
     assertDiffs(b1, b2,
         "effective last updated time differs for Change.Id " + c1.getId()
             + " in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:06.0} != {2009-09-30 17:00:12.0}");
+            + " {2009-09-30 17:00:12.0} != {2009-09-30 17:00:18.0}");
   }
 
   @Test
@@ -1259,7 +1268,9 @@
   }
 
   private static List<PatchSet> latest(Change c) {
-    return ImmutableList.of(new PatchSet(c.currentPatchSetId()));
+    PatchSet ps = new PatchSet(c.currentPatchSetId());
+    ps.setCreatedOn(c.getLastUpdatedOn());
+    return ImmutableList.of(ps);
   }
 
   private static List<PatchSetApproval> approvals(PatchSetApproval... ents) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index ab37ec9..a893ea8 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -449,6 +449,7 @@
   }
 
   private RevCommit writeCommit(String body) throws Exception {
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(body, noteUtil.newIdent(
         changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent,
         "Anonymous Coward"));
@@ -496,6 +497,7 @@
 
   private ChangeNotesParser newParser(ObjectId tip) throws Exception {
     walk.reset();
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return new ChangeNotesParser(
         newChange().getId(), tip, walk, noteUtil, args.metrics);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 0173b05..29c11ff 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -19,12 +19,10 @@
 import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static com.google.gerrit.testutil.TestChanges.incrementPatchSet;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
@@ -34,21 +32,23 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Ordering;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.TestChanges;
@@ -76,6 +76,12 @@
   @Inject
   private DraftCommentNotes.Factory draftNotesFactory;
 
+  @Inject
+  private ChangeNoteUtil noteUtil;
+
+  @Inject
+  private @GerritServerId String serverId;
+
   @Test
   public void tagChangeMessage() throws Exception {
     String tag = "jenkins";
@@ -97,18 +103,19 @@
     Change c = newChange();
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putComment(newPublishedComment(c.currentPatchSetId(), "a.txt",
-        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
-        TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
+    update.putComment(Status.PUBLISHED,
+        newComment(c.currentPatchSetId(), "a.txt", "uuid1",
+            new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
+            TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
     update.setTag(tag);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<RevId, PatchLineComment> comments = notes.getComments();
+    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
     assertThat(comments).hasSize(1);
     assertThat(
-        comments.entries().asList().get(0).getValue().getTag())
+        comments.entries().asList().get(0).getValue().tag)
             .isEqualTo(tag);
   }
 
@@ -131,10 +138,8 @@
 
     ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals =
         notes.getApprovals();
-    assertThat(approvals).hasSize(2);
+    assertThat(approvals).hasSize(1);
     assertThat(approvals.entries().asList().get(0).getValue().getTag())
-        .isEqualTo(tag1);
-    assertThat(approvals.entries().asList().get(1).getValue().getTag())
         .isEqualTo(tag2);
   }
 
@@ -153,9 +158,10 @@
 
     RevCommit commit = tr.commit().message("PS2").create();
     update = newUpdate(c, changeOwner);
-    update.putComment(newPublishedComment(c.currentPatchSetId(), "a.txt",
-        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
-        TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
+    update.putComment(Status.PUBLISHED,
+        newComment(c.currentPatchSetId(), "a.txt", "uuid1",
+            new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
+            TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
     update.setChangeMessage("coverage verification");
     update.setTag(coverageTag);
     update.commit();
@@ -174,10 +180,9 @@
     assertThat(approval.getTag()).isEqualTo(integrationTag);
     assertThat(approval.getValue()).isEqualTo(-1);
 
-    ImmutableListMultimap<RevId, PatchLineComment> comments =
-        notes.getComments();
+    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
     assertThat(comments).hasSize(1);
-    assertThat(comments.entries().asList().get(0).getValue().getTag())
+    assertThat(comments.entries().asList().get(0).getValue().tag)
         .isEqualTo(coverageTag);
 
     ImmutableList<ChangeMessage> messages = notes.getChangeMessages();
@@ -245,7 +250,7 @@
     assertThat(psa2.getAccountId().get()).isEqualTo(1);
     assertThat(psa2.getLabel()).isEqualTo("Code-Review");
     assertThat(psa2.getValue()).isEqualTo((short) +1);
-    assertThat(psa2.getGranted()).isEqualTo(truncate(after(c, 3000)));
+    assertThat(psa2.getGranted()).isEqualTo(truncate(after(c, 4000)));
   }
 
   @Test
@@ -322,7 +327,10 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals()).isEmpty();
+    assertThat(notes.getApprovals()).containsExactlyEntriesIn(
+        ImmutableMultimap.of(
+            psa.getPatchSetId(),
+            new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
   }
 
   @Test
@@ -344,7 +352,10 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals()).isEmpty();
+    assertThat(notes.getApprovals()).containsExactlyEntriesIn(
+        ImmutableMultimap.of(
+            psa.getPatchSetId(),
+            new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
 
     // Add back approval on same label.
     update = newUpdate(c, otherUser);
@@ -368,13 +379,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals = Ordering.natural().onResultOf(
-        new Function<PatchSetApproval, Integer>() {
-          @Override
-          public Integer apply(PatchSetApproval in) {
-            return in.getAccountId().get();
-          }
-        }).sortedCopy(notes.getApprovals().get(c.currentPatchSetId()));
+    List<PatchSetApproval> approvals = ReviewDbUtil.intKeyOrdering()
+        .onResultOf(PatchSetApproval::getAccountId)
+        .sortedCopy(notes.getApprovals().get(c.currentPatchSetId()));
     assertThat(approvals).hasSize(2);
 
     assertThat(approvals.get(0).getAccountId())
@@ -389,6 +396,81 @@
   }
 
   @Test
+  public void approvalsPostSubmit() throws Exception {
+    Change c = newChange();
+    RequestId submissionId = RequestId.forChange(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.putApproval("Verified", (short) 1);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.merge(submissionId, ImmutableList.of(
+        submitRecord("NOT_READY", null,
+          submitLabel("Verified", "OK", changeOwner.getAccountId()),
+          submitLabel("Code-Review", "NEED", null))));
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    List<PatchSetApproval> approvals =
+        Lists.newArrayList(notes.getApprovals().values());
+    assertThat(approvals).hasSize(2);
+    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
+    assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
+    assertThat(approvals.get(0).isPostSubmit()).isFalse();
+    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).getValue()).isEqualTo((short) 2);
+    assertThat(approvals.get(1).isPostSubmit()).isTrue();
+  }
+
+  @Test
+  public void approvalsDuringSubmit() throws Exception {
+    Change c = newChange();
+    RequestId submissionId = RequestId.forChange(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.putApproval("Verified", (short) 1);
+    update.commit();
+
+    Account.Id ownerId = changeOwner.getAccountId();
+    Account.Id otherId = otherUser.getAccountId();
+    update = newUpdate(c, otherUser);
+    update.merge(submissionId, ImmutableList.of(
+        submitRecord("NOT_READY", null,
+          submitLabel("Verified", "OK", ownerId),
+          submitLabel("Code-Review", "NEED", null))));
+    update.putApproval("Other-Label", (short) 1);
+    update.putApprovalFor(ownerId, "Code-Review", (short) 2);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    update.putApproval("Other-Label", (short) 2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    List<PatchSetApproval> approvals =
+        Lists.newArrayList(notes.getApprovals().values());
+    assertThat(approvals).hasSize(3);
+    assertThat(approvals.get(0).getAccountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
+    assertThat(approvals.get(0).getValue()).isEqualTo(1);
+    assertThat(approvals.get(0).isPostSubmit()).isFalse();
+    assertThat(approvals.get(1).getAccountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).getValue()).isEqualTo(2);
+    assertThat(approvals.get(1).isPostSubmit()).isFalse(); // During submit.
+    assertThat(approvals.get(2).getAccountId()).isEqualTo(otherId);
+    assertThat(approvals.get(2).getLabel()).isEqualTo("Other-Label");
+    assertThat(approvals.get(2).getValue()).isEqualTo(2);
+    assertThat(approvals.get(2).isPostSubmit()).isTrue();
+  }
+
+  @Test
   public void multipleReviewers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -553,6 +635,72 @@
   }
 
   @Test
+  public void assigneeCommit() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    ObjectId result = update.commit();
+    assertThat(result).isNotNull();
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(update.getResult());
+      rw.parseBody(commit);
+      String strIdent =
+          otherUser.getName()
+          + " <"
+          + otherUserId
+          + "@"
+          + serverId
+          + ">";
+      assertThat(commit.getFullMessage())
+          .contains("Assignee: " + strIdent);
+    }
+  }
+
+  @Test
+  public void assigneeChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getAssignee()).isEqualTo(otherUserId);
+
+    update = newUpdate(c, changeOwner);
+    update.setAssignee(changeOwner.getAccountId());
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getChange().getAssignee())
+        .isEqualTo(changeOwner.getAccountId());
+  }
+
+  @Test
+  public void pastAssigneesChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    update = newUpdate(c, changeOwner);
+    update.setAssignee(changeOwner.getAccountId());
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeAssignee();
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getPastAssignees()).hasSize(2);
+  }
+
+  @Test
   public void hashtagCommit() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -724,10 +872,6 @@
     assertThat(ts4).isGreaterThan(ts3);
 
     incrementPatchSet(c);
-    RevCommit commit = tr.commit().message("PS2").create();
-    update = newUpdate(c, changeOwner);
-    update.setCommit(rw, commit);
-    update.commit();
     Timestamp ts5 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts5).isGreaterThan(ts4);
 
@@ -834,11 +978,7 @@
     assertThat(ps1.getUploader()).isEqualTo(changeOwner.getAccountId());
 
     // ps2 by other user
-    incrementPatchSet(c);
-    RevCommit commit = tr.commit().message("PS2").create();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    update.setCommit(rw, commit);
-    update.commit();
+    RevCommit commit = incrementPatchSet(c, otherUser);
     notes = newNotes(c);
     PatchSet ps2 = notes.getCurrentPatchSet();
     assertThat(ps2.getId()).isEqualTo(new PatchSet.Id(c.getId(), 2));
@@ -849,10 +989,11 @@
     assertThat(ps2.getRevision().get()).isNotEqualTo(ps1.getRevision());
     assertThat(ps2.getRevision().get()).isEqualTo(commit.name());
     assertThat(ps2.getUploader()).isEqualTo(otherUser.getAccountId());
-    assertThat(ps2.getCreatedOn()).isEqualTo(update.getWhen());
+    assertThat(ps2.getCreatedOn())
+        .isEqualTo(notes.getChange().getLastUpdatedOn());
 
     // comment on ps1, current patch set is still ps2
-    update = newUpdate(c, changeOwner);
+    ChangeUpdate update = newUpdate(c, changeOwner);
     update.setPatchSetId(ps1.getId());
     update.setChangeMessage("Comment on old patch set.");
     update.commit();
@@ -865,8 +1006,7 @@
     Change c = newChange();
     PatchSet.Id psId1 = c.currentPatchSetId();
 
-    // ps2
-    incrementPatchSet(c);
+    incrementCurrentPatchSetFieldOnly(c);
     PatchSet.Id psId2 = c.currentPatchSetId();
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -874,9 +1014,10 @@
     update.setPatchSetState(PatchSetState.DRAFT);
     update.putApproval("Code-Review", (short) 1);
     update.setChangeMessage("This is a message");
-    update.putComment(newPublishedComment(c.currentPatchSetId(), "a.txt",
-        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
-        TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
+    update.putComment(Status.PUBLISHED,
+        newComment(c.currentPatchSetId(), "a.txt", "uuid1",
+            new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
+            TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -924,8 +1065,7 @@
     assertThat(notes.getPatchSets().get(psId1).getGroups())
       .containsExactly("a", "b").inOrder();
 
-    // ps2
-    incrementPatchSet(c);
+    incrementCurrentPatchSetFieldOnly(c);
     PatchSet.Id psId2 = c.currentPatchSetId();
     update = newUpdate(c, changeOwner);
     update.setCommit(rw, tr.commit().message("PS2").create());
@@ -951,7 +1091,7 @@
     // ps2 with push cert
     Change c = newChange();
     PatchSet.Id psId1 = c.currentPatchSetId();
-    incrementPatchSet(c);
+    incrementCurrentPatchSetFieldOnly(c);
     PatchSet.Id psId2 = c.currentPatchSetId();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setPatchSetId(psId2);
@@ -960,7 +1100,10 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(readNote(notes, commit)).isEqualTo(pushCert);
+    String note = readNote(notes, commit);
+    if (!testJson()) {
+      assertThat(note).isEqualTo(pushCert);
+    }
     Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
     assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
@@ -970,29 +1113,33 @@
     update = newUpdate(c, changeOwner);
     update.setPatchSetId(psId2);
     Timestamp ts = TimeUtil.nowTs();
-    update.putComment(newPublishedComment(psId2, "a.txt",
-        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null, ts,
-        "Comment", (short) 1, commit.name()));
+    update.putComment(Status.PUBLISHED,
+        newComment(psId2, "a.txt", "uuid1", new CommentRange(1, 2, 3, 4), 1,
+            changeOwner, null, ts, "Comment", (short) 1, commit.name()));
     update.commit();
 
     notes = newNotes(c);
-    assertThat(readNote(notes, commit)).isEqualTo(
-        pushCert
-        + "Revision: " + commit.name() + "\n"
-        + "Patch-set: 2\n"
-        + "File: a.txt\n"
-        + "\n"
-        + "1:2-3:4\n"
-        + ChangeNoteUtil.formatTime(serverIdent, ts) + "\n"
-        + "Author: Change Owner <1@gerrit>\n"
-        + "UUID: uuid1\n"
-        + "Bytes: 7\n"
-        + "Comment\n"
-        + "\n");
+
     patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
     assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
     assertThat(notes.getComments()).isNotEmpty();
+
+    if (!testJson()) {
+      assertThat(readNote(notes, commit)).isEqualTo(
+          pushCert
+              + "Revision: " + commit.name() + "\n"
+              + "Patch-set: 2\n"
+              + "File: a.txt\n"
+              + "\n"
+              + "1:2-3:4\n"
+              + ChangeNoteUtil.formatTime(serverIdent, ts) + "\n"
+              + "Author: Change Owner <1@gerrit>\n"
+              + "UUID: uuid1\n"
+              + "Bytes: 7\n"
+              + "Comment\n"
+              + "\n");
+    }
   }
 
   @Test
@@ -1046,11 +1193,11 @@
     RevCommit tipCommit;
     try (NoteDbUpdateManager updateManager =
         updateManagerFactory.create(project)) {
-      PatchLineComment comment1 = newPublishedComment(psId, "file1",
+      Comment comment1 = newComment(psId, "file1",
           uuid1, range1, range1.getEndLine(), otherUser, null, time1, message1,
           (short) 0, "abcd1234abcd1234abcd1234abcd1234abcd1234");
       update1.setPatchSetId(psId);
-      update1.putComment(comment1);
+      update1.putComment(Status.PUBLISHED, comment1);
       updateManager.add(update1);
 
       ChangeUpdate update2 = newUpdate(c, otherUser);
@@ -1273,11 +1420,11 @@
     PatchSet.Id psId = c.currentPatchSetId();
     RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    PatchLineComment comment = newPublishedComment(psId, "file1",
+    Comment comment = newComment(psId, "file1",
         "uuid", null, 0, otherUser, null,
         TimeUtil.nowTs(), "message", (short) 1, revId.get());
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1293,11 +1440,11 @@
     RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 0, 2, 0);
 
-    PatchLineComment comment = newPublishedComment(psId, "file1",
+    Comment comment = newComment(psId, "file1",
         "uuid", range, range.getEndLine(), otherUser, null,
         TimeUtil.nowTs(), "message", (short) 1, revId.get());
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1313,11 +1460,11 @@
     RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(0, 0, 0, 0);
 
-    PatchLineComment comment = newPublishedComment(psId, "file",
+    Comment comment = newComment(psId, "file",
         "uuid", range, range.getEndLine(), otherUser, null,
         TimeUtil.nowTs(), "message", (short) 1, revId.get());
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1333,11 +1480,10 @@
     RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 2, 3, 4);
 
-    PatchLineComment comment = newPublishedComment(psId, "",
-        "uuid", range, range.getEndLine(), otherUser, null,
-        TimeUtil.nowTs(), "message", (short) 1, revId.get());
+    Comment comment = newComment(psId, "", "uuid", range, range.getEndLine(),
+        otherUser, null, TimeUtil.nowTs(), "message", (short) 1, revId.get());
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1361,29 +1507,29 @@
     Timestamp time3 = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment comment1 = newPublishedComment(psId, "file1",
-        uuid1, range1, range1.getEndLine(), otherUser, null, time1, message1,
-        (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    Comment comment1 = newComment(psId, "file1", uuid1, range1,
+        range1.getEndLine(), otherUser, null, time1, message1, (short) 1,
+        "abcd1234abcd1234abcd1234abcd1234abcd1234");
     update.setPatchSetId(psId);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
     CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    PatchLineComment comment2 = newPublishedComment(psId, "file1",
-        uuid2, range2, range2.getEndLine(), otherUser, null, time2, message2,
-        (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    Comment comment2 = newComment(psId, "file1", uuid2, range2,
+        range2.getEndLine(), otherUser, null, time2, message2, (short) 1,
+        "abcd1234abcd1234abcd1234abcd1234abcd1234");
     update.setPatchSetId(psId);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
     update = newUpdate(c, otherUser);
     CommentRange range3 = new CommentRange(3, 0, 4, 1);
-    PatchLineComment comment3 = newPublishedComment(psId, "file2",
-        uuid3, range3, range3.getEndLine(), otherUser, null, time3, message3,
-        (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    Comment comment3 = newComment(psId, "file2", uuid3, range3,
+        range3.getEndLine(), otherUser, null, time3, message3, (short) 1,
+        "abcd1234abcd1234abcd1234abcd1234abcd1234");
     update.setPatchSetId(psId);
-    update.putComment(comment3);
+    update.putComment(Status.PUBLISHED, comment3);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1397,34 +1543,37 @@
           walk.getObjectReader().open(
               note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
-      assertThat(noteString).isEqualTo(
-          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-          + "Patch-set: 1\n"
-          + "File: file1\n"
-          + "\n"
-          + "1:1-2:1\n"
-          + ChangeNoteUtil.formatTime(serverIdent, time1) + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid1\n"
-          + "Bytes: 9\n"
-          + "comment 1\n"
-          + "\n"
-          + "2:1-3:1\n"
-          + ChangeNoteUtil.formatTime(serverIdent, time2) + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid2\n"
-          + "Bytes: 9\n"
-          + "comment 2\n"
-          + "\n"
-          + "File: file2\n"
-          + "\n"
-          + "3:0-4:1\n"
-          + ChangeNoteUtil.formatTime(serverIdent, time3) + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid3\n"
-          + "Bytes: 9\n"
-          + "comment 3\n"
-          + "\n");
+
+      if (!testJson()) {
+        assertThat(noteString).isEqualTo(
+            "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                + "Patch-set: 1\n"
+                + "File: file1\n"
+                + "\n"
+                + "1:1-2:1\n"
+                + ChangeNoteUtil.formatTime(serverIdent, time1) + "\n"
+                + "Author: Other Account <2@gerrit>\n"
+                + "UUID: uuid1\n"
+                + "Bytes: 9\n"
+                + "comment 1\n"
+                + "\n"
+                + "2:1-3:1\n"
+                + ChangeNoteUtil.formatTime(serverIdent, time2) + "\n"
+                + "Author: Other Account <2@gerrit>\n"
+                + "UUID: uuid2\n"
+                + "Bytes: 9\n"
+                + "comment 2\n"
+                + "\n"
+                + "File: file2\n"
+                + "\n"
+                + "3:0-4:1\n"
+                + ChangeNoteUtil.formatTime(serverIdent, time3) + "\n"
+                + "Author: Other Account <2@gerrit>\n"
+                + "UUID: uuid3\n"
+                + "Bytes: 9\n"
+                + "comment 3\n"
+                + "\n");
+      }
     }
   }
 
@@ -1441,20 +1590,20 @@
     Timestamp time2 = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment comment1 = newPublishedComment(psId, "file1",
+    Comment comment1 = newComment(psId, "file1",
         uuid1, range1, range1.getEndLine(), otherUser, null, time1, message1,
         (short) 0, "abcd1234abcd1234abcd1234abcd1234abcd1234");
     update.setPatchSetId(psId);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
     CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    PatchLineComment comment2 = newPublishedComment(psId, "file1",
+    Comment comment2 = newComment(psId, "file1",
         uuid2, range2, range2.getEndLine(), otherUser, null, time2, message2,
         (short) 0, "abcd1234abcd1234abcd1234abcd1234abcd1234");
     update.setPatchSetId(psId);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1468,25 +1617,28 @@
           walk.getObjectReader().open(
               note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
-      assertThat(noteString).isEqualTo(
-          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-          + "Base-for-patch-set: 1\n"
-          + "File: file1\n"
-          + "\n"
-          + "1:1-2:1\n"
-          + ChangeNoteUtil.formatTime(serverIdent, time1) + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid1\n"
-          + "Bytes: 9\n"
-          + "comment 1\n"
-          + "\n"
-          + "2:1-3:1\n"
-          + ChangeNoteUtil.formatTime(serverIdent, time2) + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid2\n"
-          + "Bytes: 9\n"
-          + "comment 2\n"
-          + "\n");
+
+      if (!testJson()) {
+        assertThat(noteString).isEqualTo(
+            "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                + "Base-for-patch-set: 1\n"
+                + "File: file1\n"
+                + "\n"
+                + "1:1-2:1\n"
+                + ChangeNoteUtil.formatTime(serverIdent, time1) + "\n"
+                + "Author: Other Account <2@gerrit>\n"
+                + "UUID: uuid1\n"
+                + "Bytes: 9\n"
+                + "comment 1\n"
+                + "\n"
+                + "2:1-3:1\n"
+                + ChangeNoteUtil.formatTime(serverIdent, time2) + "\n"
+                + "Author: Other Account <2@gerrit>\n"
+                + "UUID: uuid2\n"
+                + "Bytes: 9\n"
+                + "comment 2\n"
+                + "\n");
+      }
     }
   }
 
@@ -1494,6 +1646,9 @@
   public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId()
       throws Exception {
     Change c = newChange();
+    PatchSet.Id psId1 = c.currentPatchSetId();
+    incrementPatchSet(c);
+    PatchSet.Id psId2 = c.currentPatchSetId();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
     String uuid3 = "uuid3";
@@ -1505,24 +1660,21 @@
     Timestamp time = TimeUtil.nowTs();
     RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    PatchSet.Id psId1 = c.currentPatchSetId();
-    PatchSet.Id psId2 = new PatchSet.Id(c.getId(), psId1.get() + 1);
-
-    PatchLineComment comment1 = newPublishedComment(psId1, "file1",
-        uuid1, range1, range1.getEndLine(), otherUser, null, time, message1,
-        (short) 0, revId.get());
-    PatchLineComment comment2 = newPublishedComment(psId1, "file1",
-        uuid2, range2, range2.getEndLine(), otherUser, null, time, message2,
-        (short) 0, revId.get());
-    PatchLineComment comment3 = newPublishedComment(psId2, "file1",
-        uuid3, range1, range1.getEndLine(), otherUser, null, time, message3,
-        (short) 0, revId.get());
+    Comment comment1 =
+        newComment(psId1, "file1", uuid1, range1, range1.getEndLine(),
+            otherUser, null, time, message1, (short) 0, revId.get());
+    Comment comment2 =
+        newComment(psId1, "file1", uuid2, range2, range2.getEndLine(),
+            otherUser, null, time, message2, (short) 0, revId.get());
+    Comment comment3 =
+        newComment(psId2, "file1", uuid3, range1, range1.getEndLine(),
+            otherUser, null, time, message3, (short) 0, revId.get());
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId2);
-    update.putComment(comment3);
-    update.putComment(comment2);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment3);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1537,37 +1689,39 @@
               note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
       String timeStr = ChangeNoteUtil.formatTime(serverIdent, time);
-      assertThat(noteString).isEqualTo(
-          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-          + "Base-for-patch-set: 1\n"
-          + "File: file1\n"
-          + "\n"
-          + "1:1-2:1\n"
-          + timeStr + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid1\n"
-          + "Bytes: 9\n"
-          + "comment 1\n"
-          + "\n"
-          + "2:1-3:1\n"
-          + timeStr + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid2\n"
-          + "Bytes: 9\n"
-          + "comment 2\n"
-          + "\n"
-          + "Base-for-patch-set: 2\n"
-          + "File: file1\n"
-          + "\n"
-          + "1:1-2:1\n"
-          + timeStr + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid3\n"
-          + "Bytes: 9\n"
-          + "comment 3\n"
-          + "\n");
-    }
 
+      if (!testJson()) {
+        assertThat(noteString).isEqualTo(
+            "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                + "Base-for-patch-set: 1\n"
+                + "File: file1\n"
+                + "\n"
+                + "1:1-2:1\n"
+                + timeStr + "\n"
+                + "Author: Other Account <2@gerrit>\n"
+                + "UUID: uuid1\n"
+                + "Bytes: 9\n"
+                + "comment 1\n"
+                + "\n"
+                + "2:1-3:1\n"
+                + timeStr + "\n"
+                + "Author: Other Account <2@gerrit>\n"
+                + "UUID: uuid2\n"
+                + "Bytes: 9\n"
+                + "comment 2\n"
+                + "\n"
+                + "Base-for-patch-set: 2\n"
+                + "File: file1\n"
+                + "\n"
+                + "1:1-2:1\n"
+                + timeStr + "\n"
+                + "Author: Other Account <2@gerrit>\n"
+                + "UUID: uuid3\n"
+                + "Bytes: 9\n"
+                + "comment 3\n"
+                + "\n");
+      }
+    }
     assertThat(notes.getComments()).isEqualTo(
         ImmutableMultimap.of(
             revId, comment1,
@@ -1576,6 +1730,59 @@
   }
 
   @Test
+  public void patchLineCommentNotesFormatRealAuthor() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser =
+        userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    String uuid = "uuid";
+    String message = "comment";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    Timestamp time = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+
+    Comment comment = newComment(psId, "file", uuid, range,
+        range.getEndLine(), otherUser, null, time, message, (short) 1,
+        revId.get());
+    comment.setRealAuthor(changeOwner.getAccountId());
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree =
+          Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes =
+          walk.getObjectReader().open(
+              note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+
+      if (!testJson()) {
+        assertThat(noteString).isEqualTo(
+            "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                + "Patch-set: 1\n"
+                + "File: file\n"
+                + "\n"
+                + "1:1-2:1\n"
+                + ChangeNoteUtil.formatTime(serverIdent, time) + "\n"
+                + "Author: Other Account <2@gerrit>\n"
+                + "Real-author: Change Owner <1@gerrit>\n"
+                + "UUID: uuid\n"
+                + "Bytes: 7\n"
+                + "comment\n"
+                + "\n");
+      }
+    }
+    assertThat(notes.getComments())
+        .isEqualTo(ImmutableMultimap.of(revId, comment));
+  }
+
+  @Test
   public void patchLineCommentNotesFormatWeirdUser() throws Exception {
     Account account = new Account(new Account.Id(3), TimeUtil.nowTs());
     account.setFullName("Weird\n\u0002<User>\n");
@@ -1590,11 +1797,11 @@
     Timestamp time = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment comment = newPublishedComment(psId, "file1",
-        uuid, range, range.getEndLine(), user, null, time, "comment",
-        (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    Comment comment = newComment(psId, "file1", uuid, range, range.getEndLine(),
+        user, null, time, "comment", (short) 1,
+        "abcd1234abcd1234abcd1234abcd1234abcd1234");
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1609,22 +1816,24 @@
               note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
       String timeStr = ChangeNoteUtil.formatTime(serverIdent, time);
-      assertThat(noteString).isEqualTo(
-          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-          + "Patch-set: 1\n"
-          + "File: file1\n"
-          + "\n"
-          + "1:1-2:1\n"
-          + timeStr + "\n"
-          + "Author: Weird\u0002User <3@gerrit>\n"
-          + "UUID: uuid\n"
-          + "Bytes: 7\n"
-          + "comment\n"
-          + "\n");
-    }
 
+      if (!testJson()) {
+        assertThat(noteString).isEqualTo(
+            "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                + "Patch-set: 1\n"
+                + "File: file1\n"
+                + "\n"
+                + "1:1-2:1\n"
+                + timeStr + "\n"
+                + "Author: Weird\u0002User <3@gerrit>\n"
+                + "UUID: uuid\n"
+                + "Bytes: 7\n"
+                + "comment\n"
+                + "\n");
+      }
+    }
     assertThat(notes.getComments())
-        .isEqualTo(ImmutableMultimap.of(comment.getRevId(), comment));
+        .isEqualTo(ImmutableMultimap.of(new RevId(comment.revId), comment));
   }
 
   @Test
@@ -1642,21 +1851,20 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment commentForBase =
-        newPublishedComment(psId, "filename", uuid1,
-        range, range.getEndLine(), otherUser, null, now, messageForBase,
-        (short) 0, rev1);
+    Comment commentForBase =
+        newComment(psId, "filename", uuid1, range, range.getEndLine(),
+            otherUser, null, now, messageForBase, (short) 0, rev1);
     update.setPatchSetId(psId);
-    update.putComment(commentForBase);
+    update.putComment(Status.PUBLISHED, commentForBase);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    PatchLineComment commentForPS =
-        newPublishedComment(psId, "filename", uuid2,
-        range, range.getEndLine(), otherUser, null, now, messageForPS,
+    Comment commentForPS =
+        newComment(psId, "filename", uuid2, range, range.getEndLine(),
+            otherUser, null, now, messageForPS,
         (short) 1, rev2);
     update.setPatchSetId(psId);
-    update.putComment(commentForPS);
+    update.putComment(Status.PUBLISHED, commentForPS);
     update.commit();
 
     assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
@@ -1679,19 +1887,19 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp timeForComment1 = TimeUtil.nowTs();
     Timestamp timeForComment2 = TimeUtil.nowTs();
-    PatchLineComment comment1 = newPublishedComment(psId, filename,
-        uuid1, range, range.getEndLine(), otherUser, null, timeForComment1,
-        "comment 1", side, rev);
+    Comment comment1 =
+        newComment(psId, filename, uuid1, range, range.getEndLine(), otherUser,
+            null, timeForComment1, "comment 1", side, rev);
     update.setPatchSetId(psId);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    PatchLineComment comment2 = newPublishedComment(psId, filename,
-        uuid2, range, range.getEndLine(), otherUser, null, timeForComment2,
-        "comment 2", side, rev);
+    Comment comment2 =
+        newComment(psId, filename, uuid2, range, range.getEndLine(), otherUser,
+            null, timeForComment2, "comment 2", side, rev);
     update.setPatchSetId(psId);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
     assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
@@ -1714,19 +1922,19 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newPublishedComment(psId, filename1,
+    Comment comment1 = newComment(psId, filename1,
         uuid, range, range.getEndLine(), otherUser, null, now, "comment 1",
         side, rev);
     update.setPatchSetId(psId);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    PatchLineComment comment2 = newPublishedComment(psId, filename2,
+    Comment comment2 = newComment(psId, filename2,
         uuid, range, range.getEndLine(), otherUser, null, now, "comment 2",
         side, rev);
     update.setPatchSetId(psId);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
     assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
@@ -1748,11 +1956,10 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newPublishedComment(ps1, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps1",
-        side, rev1);
+    Comment comment1 = newComment(ps1, filename, uuid, range,
+        range.getEndLine(), otherUser, null, now, "comment on ps1", side, rev1);
     update.setPatchSetId(ps1);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -1760,11 +1967,10 @@
 
     update = newUpdate(c, otherUser);
     now = TimeUtil.nowTs();
-    PatchLineComment comment2 = newPublishedComment(ps2, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps2",
-        side, rev2);
+    Comment comment2 = newComment(ps2, filename, uuid, range,
+        range.getEndLine(), otherUser, null, now, "comment on ps2", side, rev2);
     update.setPatchSetId(ps2);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
     assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
@@ -1785,11 +1991,10 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newComment(ps1, filename, uuid, range,
-        range.getEndLine(), otherUser, null, now, "comment on ps1", side,
-        rev, Status.DRAFT);
+    Comment comment1 = newComment(ps1, filename, uuid, range,
+        range.getEndLine(), otherUser, null, now, "comment on ps1", side, rev);
     update.setPatchSetId(ps1);
-    update.putComment(comment1);
+    update.putComment(Status.DRAFT, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1797,10 +2002,9 @@
         ImmutableMultimap.of(new RevId(rev), comment1));
     assertThat(notes.getComments()).isEmpty();
 
-    comment1.setStatus(Status.PUBLISHED);
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
@@ -1826,14 +2030,12 @@
     // Write two drafts on the same side of one patch set.
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    PatchLineComment comment1 = newComment(psId, filename, uuid1,
-        range1, range1.getEndLine(), otherUser, null, now, "comment on ps1",
-        side, rev, Status.DRAFT);
-    PatchLineComment comment2 = newComment(psId, filename, uuid2,
-        range2, range2.getEndLine(), otherUser, null, now, "other on ps1",
-        side, rev, Status.DRAFT);
-    update.putComment(comment1);
-    update.putComment(comment2);
+    Comment comment1 = newComment(psId, filename, uuid1, range1,
+        range1.getEndLine(), otherUser, null, now, "comment on ps1", side, rev);
+    Comment comment2 = newComment(psId, filename, uuid2, range2,
+        range2.getEndLine(), otherUser, null, now, "other on ps1", side, rev);
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1846,8 +2048,7 @@
     // Publish first draft.
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    comment1.setStatus(Status.PUBLISHED);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
@@ -1874,15 +2075,15 @@
     // Write two drafts, one on each side of the patchset.
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    PatchLineComment baseComment = newComment(psId, filename, uuid1,
-        range1, range1.getEndLine(), otherUser, null, now, "comment on base",
-        (short) 0, rev1, Status.DRAFT);
-    PatchLineComment psComment = newComment(psId, filename, uuid2,
-        range2, range2.getEndLine(), otherUser, null, now, "comment on ps",
-        (short) 1, rev2, Status.DRAFT);
+    Comment baseComment =
+        newComment(psId, filename, uuid1, range1, range1.getEndLine(),
+            otherUser, null, now, "comment on base", (short) 0, rev1);
+    Comment psComment =
+        newComment(psId, filename, uuid2, range2, range2.getEndLine(),
+            otherUser, null, now, "comment on ps", (short) 1, rev2);
 
-    update.putComment(baseComment);
-    update.putComment(psComment);
+    update.putComment(Status.DRAFT, baseComment);
+    update.putComment(Status.DRAFT, psComment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1896,10 +2097,8 @@
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
 
-    baseComment.setStatus(Status.PUBLISHED);
-    psComment.setStatus(Status.PUBLISHED);
-    update.putComment(baseComment);
-    update.putComment(psComment);
+    update.putComment(Status.PUBLISHED, baseComment);
+    update.putComment(Status.PUBLISHED, psComment);
     update.commit();
 
     notes = newNotes(c);
@@ -1923,11 +2122,10 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment = newComment(psId, filename, uuid, range,
-        range.getEndLine(), otherUser, null, now, "comment on ps1", side,
-        rev, Status.DRAFT);
+    Comment comment = newComment(psId, filename, uuid, range,
+        range.getEndLine(), otherUser, null, now, "comment on ps1", side, rev);
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.DRAFT, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1962,11 +2160,10 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newComment(ps1, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps1",
-        side, rev1, Status.DRAFT);
+    Comment comment1 = newComment(ps1, filename, uuid, range,
+        range.getEndLine(), otherUser, null, now, "comment on ps1", side, rev1);
     update.setPatchSetId(ps1);
-    update.putComment(comment1);
+    update.putComment(Status.DRAFT, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -1974,11 +2171,10 @@
 
     update = newUpdate(c, otherUser);
     now = TimeUtil.nowTs();
-    PatchLineComment comment2 = newComment(ps2, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps2",
-        side, rev2, Status.DRAFT);
+    Comment comment2 = newComment(ps2, filename, uuid, range,
+        range.getEndLine(), otherUser, null, now, "comment on ps2", side, rev2);
     update.setPatchSetId(ps2);
-    update.putComment(comment2);
+    update.putComment(Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2010,10 +2206,9 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment = newComment(ps1, filename, uuid, range,
-        range.getEndLine(), otherUser, null, now, "comment on ps1", side,
-        rev, Status.PUBLISHED);
-    update.putComment(comment);
+    Comment comment = newComment(ps1, filename, uuid, range, range.getEndLine(),
+        otherUser, null, now, "comment on ps1", side, rev);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(repo.exactRef(changeMetaRef(c.getId()))).isNotNull();
@@ -2033,10 +2228,10 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment draft = newComment(ps1, filename, "uuid1", range,
-        range.getEndLine(), otherUser, null, now, "draft comment on ps1", side,
-        rev, Status.DRAFT);
-    update.putComment(draft);
+    Comment draft =
+        newComment(ps1, filename, "uuid1", range, range.getEndLine(), otherUser,
+            null, now, "draft comment on ps1", side, rev);
+    update.putComment(Status.DRAFT, draft);
     update.commit();
 
     String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
@@ -2044,10 +2239,9 @@
     assertThat(old).isNotNull();
 
     update = newUpdate(c, otherUser);
-    PatchLineComment pub = newComment(ps1, filename, "uuid2", range,
-        range.getEndLine(), otherUser, null, now, "comment on ps1", side,
-        rev, Status.PUBLISHED);
-    update.putComment(pub);
+    Comment pub = newComment(ps1, filename, "uuid2", range, range.getEndLine(),
+        otherUser, null, now, "comment on ps1", side, rev);
+    update.putComment(Status.PUBLISHED, pub);
     update.commit();
 
     assertThat(exactRefAllUsers(draftRef)).isEqualTo(old);
@@ -2063,11 +2257,10 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment comment = newPublishedComment(
-        psId, "filename", uuid, null, 0, otherUser, null, now, messageForBase,
-        (short) 0, rev);
+    Comment comment = newComment(psId, "filename", uuid, null, 0, otherUser,
+        null, now, messageForBase, (short) 0, rev);
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
@@ -2084,11 +2277,10 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment comment = newPublishedComment(
-        psId, "filename", uuid, null, 1, otherUser, null, now, messageForBase,
-        (short) 0, rev);
+    Comment comment = newComment(psId, "filename", uuid, null, 1, otherUser,
+        null, now, messageForBase, (short) 0, rev);
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
@@ -2112,14 +2304,12 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newComment(ps1, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps1",
-        side, rev1, Status.DRAFT);
-    PatchLineComment comment2 = newComment(ps2, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps2",
-        side, rev2, Status.DRAFT);
-    update.putComment(comment1);
-    update.putComment(comment2);
+    Comment comment1 = newComment(ps1, filename, uuid, range,
+        range.getEndLine(), otherUser, null, now, "comment on ps1", side, rev1);
+    Comment comment2 = newComment(ps2, filename, uuid, range,
+        range.getEndLine(), otherUser, null, now, "comment on ps2", side, rev2);
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2128,10 +2318,8 @@
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
-    comment1.setStatus(Status.PUBLISHED);
-    comment2.setStatus(Status.PUBLISHED);
-    update.putComment(comment1);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
@@ -2150,14 +2338,12 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newComment(ps1, "file1",
-        "uuid1", range, range.getEndLine(), otherUser, null, now, "comment1",
-        side, rev1.get(), Status.DRAFT);
-    PatchLineComment comment2 = newComment(ps1, "file2",
-        "uuid2", range, range.getEndLine(), otherUser, null, now, "comment2",
-        side, rev1.get(), Status.DRAFT);
-    update.putComment(comment1);
-    update.putComment(comment2);
+    Comment comment1 = newComment(ps1, "file1", "uuid1", range,
+        range.getEndLine(), otherUser, null, now, "comment1", side, rev1.get());
+    Comment comment2 = newComment(ps1, "file2", "uuid2", range,
+        range.getEndLine(), otherUser, null, now, "comment2", side, rev1.get());
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2167,8 +2353,7 @@
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    comment2.setStatus(Status.PUBLISHED);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
@@ -2203,14 +2388,14 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newComment(ps1, "file1",
-        "uuid1", range, range.getEndLine(), otherUser, null, now, "comment on ps1",
-        side, rev1.get(), Status.DRAFT);
-    PatchLineComment comment2 = newComment(ps1, "file2",
-        "uuid2", range, range.getEndLine(), otherUser, null, now, "another comment",
-        side, rev1.get(), Status.DRAFT);
-    update.putComment(comment1);
-    update.putComment(comment2);
+    Comment comment1 =
+        newComment(ps1, "file1", "uuid1", range, range.getEndLine(), otherUser,
+            null, now, "comment on ps1", side, rev1.get());
+    Comment comment2 =
+        newComment(ps1, "file2", "uuid2", range, range.getEndLine(), otherUser,
+            null, now, "another comment", side, rev1.get());
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
     update.commit();
 
     String refName = refsDraftComments(c.getId(), otherUserId);
@@ -2218,8 +2403,7 @@
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    comment2.setStatus(Status.PUBLISHED);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
     assertThat(exactRefAllUsers(refName)).isNotNull();
     assertThat(exactRefAllUsers(refName)).isNotEqualTo(oldDraftId);
@@ -2229,7 +2413,6 @@
     // non-atomically after adding the published comment succeeded.
     ChangeDraftUpdate draftUpdate =
         newUpdate(c, otherUser).createDraftUpdateIfNull();
-    comment2.setStatus(Status.DRAFT);
     draftUpdate.putComment(comment2);
     try (NoteDbUpdateManager manager =
         updateManagerFactory.create(c.getProject())) {
@@ -2242,8 +2425,6 @@
     assertThat(draftNotes.load().getComments().get(rev1))
         .containsExactly(comment1, comment2);
 
-    comment2.setStatus(Status.PUBLISHED); // Reset for later assertions.
-
     // Zombie comment is filtered out of drafts via ChangeNotes.
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId).get(rev1))
@@ -2253,8 +2434,7 @@
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    comment1.setStatus(Status.PUBLISHED);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     // Updating an unrelated comment causes the zombie comment to get fixed up.
@@ -2268,18 +2448,16 @@
     String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
 
     ChangeUpdate update1 = newUpdate(c, otherUser);
-    PatchLineComment comment1 = newComment(c.currentPatchSetId(), "filename",
+    Comment comment1 = newComment(c.currentPatchSetId(), "filename",
         "uuid1", range, range.getEndLine(), otherUser, null,
-        new Timestamp(update1.getWhen().getTime()), "comment 1", (short) 1, rev,
-        Status.PUBLISHED);
-    update1.putComment(comment1);
+        new Timestamp(update1.getWhen().getTime()), "comment 1", (short) 1, rev);
+    update1.putComment(Status.PUBLISHED, comment1);
 
     ChangeUpdate update2 = newUpdate(c, otherUser);
-    PatchLineComment comment2 = newComment(c.currentPatchSetId(), "filename",
+    Comment comment2 = newComment(c.currentPatchSetId(), "filename",
         "uuid2", range, range.getEndLine(), otherUser, null,
-        new Timestamp(update2.getWhen().getTime()), "comment 2", (short) 1, rev,
-        Status.PUBLISHED);
-    update2.putComment(comment2);
+        new Timestamp(update2.getWhen().getTime()), "comment 2", (short) 1, rev);
+    update2.putComment(Status.PUBLISHED, comment2);
 
     try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
       manager.add(update1);
@@ -2288,10 +2466,58 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<PatchLineComment> comments = notes.getComments().get(new RevId(rev));
+    List<Comment> comments = notes.getComments().get(new RevId(rev));
     assertThat(comments).hasSize(2);
-    assertThat(comments.get(0).getMessage()).isEqualTo("comment 1");
-    assertThat(comments.get(1).getMessage()).isEqualTo("comment 2");
+    assertThat(comments.get(0).message).isEqualTo("comment 1");
+    assertThat(comments.get(1).message).isEqualTo("comment 2");
+  }
+
+  @Test
+  public void realUser() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser =
+        userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    update.setChangeMessage("Message on behalf of other user");
+    update.commit();
+
+    ChangeMessage msg = Iterables.getLast(newNotes(c).getChangeMessages());
+    assertThat(msg.getMessage()).isEqualTo("Message on behalf of other user");
+    assertThat(msg.getAuthor()).isEqualTo(otherUserId);
+    assertThat(msg.getRealAuthor()).isEqualTo(changeOwner.getAccountId());
+  }
+
+  @Test
+  public void ignoreEntitiesBeyondCurrentPatchSet() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    int numMessages = notes.getChangeMessages().size();
+    int numPatchSets = notes.getPatchSets().size();
+    int numApprovals = notes.getApprovals().size();
+    int numComments = notes.getComments().size();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPatchSetId(
+        new PatchSet.Id(c.getId(), c.currentPatchSetId().get() + 1));
+    update.setChangeMessage("Should be ignored");
+    update.putApproval("Code-Review", (short) 2);
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    Comment comment = newComment(update.getPatchSetId(), "filename",
+        "uuid", range, range.getEndLine(), changeOwner, null,
+        new Timestamp(update.getWhen().getTime()), "comment", (short) 1,
+        "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getChangeMessages()).hasSize(numMessages);
+    assertThat(notes.getPatchSets()).hasSize(numPatchSets);
+    assertThat(notes.getApprovals()).hasSize(numApprovals);
+    assertThat(notes.getComments()).hasSize(numComments);
+  }
+
+  private boolean testJson() {
+    return noteUtil.getWriteJson();
   }
 
   private String readNote(ChangeNotes notes, ObjectId noteId) throws Exception {
@@ -2322,4 +2548,24 @@
         .isNotNull();
     assertThat(cause.getMessage()).isEqualTo(expectedMsg);
   }
+
+  private void incrementCurrentPatchSetFieldOnly(Change c) {
+    TestChanges.incrementPatchSet(c);
+  }
+
+  private RevCommit incrementPatchSet(Change c) throws Exception {
+    return incrementPatchSet(c, userFactory.create(c.getOwner()));
+  }
+
+  private RevCommit incrementPatchSet(Change c, IdentifiedUser user)
+      throws Exception {
+    incrementCurrentPatchSetFieldOnly(c);
+    RevCommit commit = tr.commit()
+        .message("PS" + c.currentPatchSetId().get())
+        .create();
+    ChangeUpdate update = newUpdate(c, user);
+    update.setCommit(rw, commit);
+    update.commit();
+    return tr.parseBody(commit);
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index bf5abba..b674159 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -22,7 +22,9 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestChanges;
 
 import org.eclipse.jgit.lib.ObjectId;
@@ -30,10 +32,12 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
+import org.junit.runner.RunWith;
 
 import java.util.Date;
 import java.util.TimeZone;
 
+@RunWith(ConfigSuite.class)
 public class CommitMessageOutputTest extends AbstractChangeNotesTest {
   @Test
   public void approvalsCommitFormatSimple() throws Exception {
@@ -329,6 +333,29 @@
         update.getResult());
   }
 
+  @Test
+  public void realUser() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser =
+        userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    update.setChangeMessage("Message on behalf of other user");
+    update.commit();
+
+    RevCommit commit = parseCommit(update.getResult());
+    PersonIdent author = commit.getAuthorIdent();
+    assertThat(author.getName()).isEqualTo("Other Account");
+    assertThat(author.getEmailAddress()).isEqualTo("2@gerrit");
+
+    assertBodyEquals("Update patch set 1\n"
+        + "\n"
+        + "Message on behalf of other user\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Real-user: Change Owner <1@gerrit>\n",
+        commit);
+  }
+
   private RevCommit parseCommit(ObjectId id) throws Exception {
     if (id instanceof RevCommit) {
       return (RevCommit) id;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
index 216f71b..e3613e3 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
@@ -18,9 +18,10 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.notedb.NoteDbChangeState.applyDelta;
 import static com.google.gerrit.server.notedb.NoteDbChangeState.parse;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.NOTE_DB;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
 import static org.eclipse.jgit.lib.ObjectId.zeroId;
 
-import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -33,6 +34,8 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
+import java.util.Optional;
+
 /** Unit tests for {@link NoteDbChangeState}. */
 public class NoteDbChangeStateTest {
   static {
@@ -47,30 +50,44 @@
       ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee");
 
   @Test
-  public void parseWithoutDrafts() {
+  public void parseReviewDbWithoutDrafts() {
     NoteDbChangeState state = parse(new Change.Id(1), SHA1.name());
-
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
     assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
     assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
     assertThat(state.getDraftIds()).isEmpty();
+    assertThat(state.toString()).isEqualTo(SHA1.name());
 
+    state = parse(new Change.Id(1), "R," + SHA1.name());
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getDraftIds()).isEmpty();
     assertThat(state.toString()).isEqualTo(SHA1.name());
   }
 
   @Test
-  public void parseWithDrafts() {
-    NoteDbChangeState state = parse(
-        new Change.Id(1),
-        SHA1.name() + ",2003=" + SHA2.name() + ",1001=" + SHA3.name());
-
+  public void parseReviewDbWithDrafts() {
+    String str = SHA1.name() + ",2003=" + SHA2.name() + ",1001=" + SHA3.name();
+    String expected =
+        SHA1.name() + ",1001=" + SHA3.name() + ",2003=" + SHA2.name();
+    NoteDbChangeState state = parse(new Change.Id(1), str);
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
     assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
     assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
     assertThat(state.getDraftIds()).containsExactly(
         new Account.Id(1001), SHA3,
         new Account.Id(2003), SHA2);
+    assertThat(state.toString()).isEqualTo(expected);
 
-    assertThat(state.toString()).isEqualTo(
-        SHA1.name() + ",1001=" + SHA3.name() + ",2003=" + SHA2.name());
+    state = parse(new Change.Id(1), "R," + str);
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getDraftIds()).containsExactly(
+        new Account.Id(1001), SHA3,
+        new Account.Id(2003), SHA2);
+    assertThat(state.toString()).isEqualTo(expected);
   }
 
   @Test
@@ -126,6 +143,27 @@
         SHA3.name() + ",1001=" + SHA2.name());
   }
 
+  @Test
+  public void parseNoteDbPrimary() {
+    NoteDbChangeState state = parse(new Change.Id(1), "N");
+    assertThat(state.getPrimaryStorage()).isEqualTo(NOTE_DB);
+    assertThat(state.getRefState().isPresent()).isFalse();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void parseInvalidPrimaryStorage() {
+    parse(new Change.Id(1), "X");
+  }
+
+  @Test
+  public void applyDeltaToNoteDbPrimaryIsNoOp() {
+    Change c = newChange();
+    c.setNoteDbState("N");
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA1),
+        drafts(new Account.Id(1001), SHA2)));
+    assertThat(c.getNoteDbState()).isEqualTo("N");
+  }
+
   private static Change newChange() {
     return TestChanges.newChange(
         new Project.NameKey("project"), new Account.Id(12345));
@@ -134,7 +172,7 @@
   // Static factory methods to avoid type arguments when using as method args.
 
   private static Optional<ObjectId> noMetaId() {
-    return Optional.absent();
+    return Optional.empty();
   }
 
   private static Optional<ObjectId> metaId(ObjectId id) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
new file mode 100644
index 0000000..1db59c5
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
@@ -0,0 +1,239 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Collections2;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.testutil.TestTimeUtil;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+public class EventSorterTest {
+  private class TestEvent extends Event {
+    protected TestEvent(Timestamp when) {
+      super(
+          new PatchSet.Id(new Change.Id(1), 1),
+          new Account.Id(1000), new Account.Id(1000),
+          when, changeCreatedOn, null);
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      return false;
+    }
+
+    @Override
+    void apply(ChangeUpdate update) {
+      throw new UnsupportedOperationException();
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public String toString() {
+      return "E{" + when.getSeconds() + '}';
+    }
+  }
+
+  private Timestamp changeCreatedOn;
+
+  @Before
+  public void setUp() {
+    TestTimeUtil.resetWithClockStep(10, TimeUnit.SECONDS);
+    changeCreatedOn = TimeUtil.nowTs();
+  }
+
+  @Test
+  public void naturalSort() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+
+    for (List<Event> events : Collections2.permutations(events(e1, e2, e3))) {
+      assertSorted(events, events(e1, e2, e3));
+    }
+  }
+
+  @Test
+  public void topoSortOneDep() {
+    List<Event> es;
+
+    // Input list is 0,1,2
+
+    // 0 depends on 1 => 1,0,2
+    es = threeEventsOneDep(0, 1);
+    assertSorted(es, events(es, 1, 0, 2));
+
+    // 1 depends on 0 => 0,1,2
+    es = threeEventsOneDep(1, 0);
+    assertSorted(es, events(es, 0, 1, 2));
+
+    // 0 depends on 2 => 1,2,0
+    es = threeEventsOneDep(0, 2);
+    assertSorted(es, events(es, 1, 2, 0));
+
+    // 2 depends on 0 => 0,1,2
+    es = threeEventsOneDep(2, 0);
+    assertSorted(es, events(es, 0, 1, 2));
+
+    // 1 depends on 2 => 0,2,1
+    es = threeEventsOneDep(1, 2);
+    assertSorted(es, events(es, 0, 2, 1));
+
+    // 2 depends on 1 => 0,1,2
+    es = threeEventsOneDep(2, 1);
+    assertSorted(es, events(es, 0, 1, 2));
+  }
+
+  private List<Event> threeEventsOneDep(int depFromIdx, int depOnIdx) {
+    List<Event> events = Lists.newArrayList(
+        new TestEvent(TimeUtil.nowTs()),
+        new TestEvent(TimeUtil.nowTs()),
+        new TestEvent(TimeUtil.nowTs()));
+    events.get(depFromIdx).addDep(events.get(depOnIdx));
+    return events;
+  }
+
+  @Test
+  public void lastEventDependsOnFirstEvent() {
+    List<Event> events = new ArrayList<>();
+    for (int i = 0; i < 20; i++) {
+      events.add(new TestEvent(TimeUtil.nowTs()));
+    }
+    events.get(events.size() - 1).addDep(events.get(0));
+    assertSorted(events, events);
+  }
+
+  @Test
+  public void firstEventDependsOnLastEvent() {
+    List<Event> events = new ArrayList<>();
+    for (int i = 0; i < 20; i++) {
+      events.add(new TestEvent(TimeUtil.nowTs()));
+    }
+    events.get(0).addDep(events.get(events.size() - 1));
+
+    List<Event> expected = new ArrayList<>();
+    expected.addAll(events.subList(1, events.size()));
+    expected.add(events.get(0));
+    assertSorted(events, expected);
+  }
+
+  @Test
+  public void topoSortChainOfDeps() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    Event e4 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e2);
+    e2.addDep(e3);
+    e3.addDep(e4);
+
+    assertSorted(
+        events(e1, e2, e3, e4),
+        events(e4, e3, e2, e1));
+  }
+
+  @Test
+  public void topoSortMultipleDeps() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    Event e4 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e2);
+    e1.addDep(e4);
+    e2.addDep(e3);
+
+    // Processing 3 pops 2, processing 4 pops 1.
+    assertSorted(
+        events(e2, e3, e1, e4),
+        events(e3, e2, e4, e1));
+  }
+
+  @Test
+  public void topoSortMultipleDepsPreservesNaturalOrder() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    Event e4 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e4);
+    e2.addDep(e4);
+    e3.addDep(e4);
+
+    // Processing 4 pops 1, 2, 3 in natural order.
+    assertSorted(
+        events(e4, e3, e2, e1),
+        events(e4, e1, e2, e3));
+  }
+
+  @Test
+  public void topoSortCycle() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+
+    // Implementation is not really defined, but infinite looping would be bad.
+    // According to current implementation details, 2 pops 1, 1 pops 2 which was
+    // already seen.
+    assertSorted(
+        events(e2, e1),
+        events(e1, e2));
+  }
+
+  @Test
+  public void topoSortDepNotInInputList() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e3);
+
+    List<Event> events = events(e2, e1);
+    try {
+      new EventSorter(events).sort();
+      fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  private static List<Event> events(Event... es) {
+    return Lists.newArrayList(es);
+  }
+
+  private static List<Event> events(List<Event> in, Integer... indexes) {
+    return Stream.of(indexes).map(in::get).collect(toList());
+  }
+
+  private static void assertSorted(List<Event> unsorted, List<Event> expected) {
+    List<Event> actual = new ArrayList<>(unsorted);
+    new EventSorter(actual).sort();
+    assertThat(actual)
+        .named("sorted" + unsorted)
+        .isEqualTo(expected);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
index d4d77bd..596ed59 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
@@ -137,13 +137,13 @@
   }
 
   private void assertCanSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit())
+    assertThat(u.controlForRef(ref).canSubmit(false))
       .named("can submit " + ref)
       .isTrue();
   }
 
   private void assertCannotSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit())
+    assertThat(u.controlForRef(ref).canSubmit(false))
       .named("can submit " + ref)
       .isFalse();
   }
@@ -685,6 +685,43 @@
   }
 
   @Test
+  public void testUnblockMoreSpecificRefInLocal_Fails() {
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master");
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void testUnblockMoreSpecificRefWithExclusiveFlag() {
+    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master", true);
+
+    ProjectControl u = user(local, DEVS);
+    assertCanUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void testUnblockMoreSpecificRefInLocalWithExclusiveFlag_Fails() {
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master", true);
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void testUnblockOtherPermissionWithMoreSpecificRefAndExclusiveFlag_Fails() {
+    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master");
+    allow(local, SUBMIT, DEVS, "refs/heads/master", true);
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
   public void testUnblockLargerScope_Fails() {
     block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
     allow(local, PUSH, DEVS, "refs/heads/*");
@@ -825,6 +862,14 @@
   }
 
   @Test
+  public void testBlockOwner() {
+    block(parent, OWNER, ANONYMOUS_USERS, "refs/*");
+    allow(local, OWNER, DEVS, "refs/*");
+
+    assertThat(user(local, DEVS).isOwner()).isFalse();
+  }
+
+  @Test
   public void testValidateRefPatternsOK() throws Exception {
     RefPattern.validate("refs/*");
     RefPattern.validate("^refs/heads/*");
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
index 772c778..d3f0bcb 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
@@ -97,6 +97,13 @@
   }
 
   public static PermissionRule allow(ProjectConfig project,
+      String permissionName, AccountGroup.UUID group, String ref,
+      boolean exclusive) {
+    return grant(project, permissionName, newRule(project, group), ref,
+        exclusive);
+  }
+
+  public static PermissionRule allow(ProjectConfig project,
       String capabilityName, AccountGroup.UUID group) {
     PermissionRule rule = newRule(project, group);
     project.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
@@ -121,6 +128,15 @@
     return rule;
   }
 
+  public static PermissionRule remove(ProjectConfig project,
+      String permissionName, AccountGroup.UUID group, String ref) {
+    PermissionRule rule = newRule(project, group);
+    project.getAccessSection(ref, true)
+        .getPermission(permissionName, true)
+        .remove(rule);
+    return rule;
+  }
+
   public static PermissionRule block(ProjectConfig project,
       String capabilityName, AccountGroup.UUID group) {
     PermissionRule rule = newRule(project, group);
@@ -163,9 +179,18 @@
 
   private static PermissionRule grant(ProjectConfig project,
       String permissionName, PermissionRule rule, String ref) {
-    project.getAccessSection(ref, true) //
-        .getPermission(permissionName, true) //
-        .add(rule);
+    return grant(project, permissionName, rule, ref, false);
+  }
+
+  private static PermissionRule grant(ProjectConfig project,
+      String permissionName, PermissionRule rule, String ref,
+      boolean exclusive) {
+    Permission permission = project.getAccessSection(ref, true)
+        .getPermission(permissionName, true);
+    if (exclusive) {
+      permission.setExclusiveGroup(exclusive);
+    }
+    permission.add(rule);
     return rule;
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index f7b3b11..8093bbb 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.query.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.Accounts.QueryRequest;
 import com.google.gerrit.extensions.client.ListAccountsOption;
@@ -37,6 +37,8 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testutil.ConfigSuite;
@@ -98,6 +100,9 @@
   @Inject
   protected ThreadLocalRequestContext requestContext;
 
+  @Inject
+  protected OneOffRequestContext oneOffRequestContext;
+
   protected LifecycleManager lifecycle;
   protected ReviewDb db;
   protected AccountInfo currentUserInfo;
@@ -277,10 +282,10 @@
     AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
 
     List<AccountInfo> result = assertQuery(domain, user1, user2, user3);
-    assertThat(result.get(result.size() - 1)._moreAccounts).isNull();
+    assertThat(Iterables.getLast(result)._moreAccounts).isNull();
 
-    result = assertQuery(newQuery(domain).withLimit(2), user1, user2);
-    assertThat(result.get(result.size() - 1)._moreAccounts).isTrue();
+    result = assertQuery(newQuery(domain).withLimit(2), result.subList(0, 2));
+    assertThat(Iterables.getLast(result)._moreAccounts).isTrue();
   }
 
   @Test
@@ -290,8 +295,8 @@
     AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
     AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
 
-    assertQuery(domain, user1, user2, user3);
-    assertQuery(newQuery(domain).withStart(1), user2, user3);
+    List<AccountInfo> result = assertQuery(domain, user1, user2, user3);
+    assertQuery(newQuery(domain).withStart(1), result.subList(1, 3));
   }
 
   @Test
@@ -406,18 +411,20 @@
 
   private Account.Id createAccount(String username, String fullName,
       String email, boolean active) throws Exception {
-    Account.Id id =
-        accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
-    if (email != null) {
-      accountManager.link(id, AuthRequest.forEmail(email));
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      Account.Id id =
+          accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      if (email != null) {
+        accountManager.link(id, AuthRequest.forEmail(email));
+      }
+      Account a = db.accounts().get(id);
+      a.setFullName(fullName);
+      a.setPreferredEmail(email);
+      a.setActive(active);
+      db.accounts().update(ImmutableList.of(a));
+      accountCache.evict(id);
+      return id;
     }
-    Account a = db.accounts().get(id);
-    a.setFullName(fullName);
-    a.setPreferredEmail(email);
-    a.setActive(active);
-    db.accounts().update(ImmutableList.of(a));
-    accountCache.evict(id);
-    return id;
   }
 
   private void addEmails(AccountInfo account, String... emails)
@@ -438,8 +445,14 @@
     return assertQuery(newQuery(query), accounts);
   }
 
-  protected List<AccountInfo> assertQuery(QueryRequest query, AccountInfo... accounts)
-      throws Exception {
+  protected List<AccountInfo> assertQuery(QueryRequest query,
+      AccountInfo... accounts) throws Exception {
+    return assertQuery(query, Arrays.asList(accounts));
+  }
+
+
+  protected List<AccountInfo> assertQuery(QueryRequest query,
+      List<AccountInfo> accounts) throws Exception {
     List<AccountInfo> result = query.get();
     Iterable<Integer> ids = ids(result);
     assertThat(ids).named(format(query, result, accounts))
@@ -447,12 +460,12 @@
     return result;
   }
 
-  private String format(QueryRequest query, Iterable<AccountInfo> actualIds,
-      AccountInfo... expectedAccounts) {
+  private String format(QueryRequest query, List<AccountInfo> actualIds,
+      List<AccountInfo> expectedAccounts) {
     StringBuilder b = new StringBuilder();
     b.append("query '").append(query.getQuery())
         .append("' with expected accounts ");
-    b.append(format(Arrays.asList(expectedAccounts)));
+    b.append(format(expectedAccounts));
     b.append(" and result ");
     b.append(format(actualIds));
     return b.toString();
@@ -476,22 +489,10 @@
   }
 
   protected static Iterable<Integer> ids(AccountInfo... accounts) {
-    return FluentIterable.from(Arrays.asList(accounts)).transform(
-        new Function<AccountInfo, Integer>() {
-          @Override
-          public Integer apply(AccountInfo in) {
-            return in._accountId;
-          }
-        });
+    return ids(Arrays.asList(accounts));
   }
 
-  protected static Iterable<Integer> ids(Iterable<AccountInfo> accounts) {
-    return FluentIterable.from(accounts).transform(
-        new Function<AccountInfo, Integer>() {
-          @Override
-          public Integer apply(AccountInfo in) {
-            return in._accountId;
-          }
-        });
+  protected static Iterable<Integer> ids(List<AccountInfo> accounts) {
+    return accounts.stream().map(a -> a._accountId).collect(toList());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 0c658bf..1941fd4 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -21,9 +21,7 @@
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
@@ -31,12 +29,14 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -244,7 +244,8 @@
     assertQuery("change:repo~branch~" + k.substring(0, 10), change);
 
     assertQuery("foo~bar");
-    assertBadQuery("change:foo~bar");
+    assertThatQueryException("change:foo~bar")
+        .hasMessage("Invalid change format");
     assertQuery("otherrepo~branch~" + k);
     assertQuery("change:otherrepo~branch~" + k);
     assertQuery("repo~otherbranch~" + k);
@@ -342,8 +343,10 @@
     assertQuery("status:N", change1);
     assertQuery("status:nE", change1);
     assertQuery("status:neW", change1);
-    assertBadQuery("status:nx");
-    assertBadQuery("status:newx");
+    assertThatQueryException("status:nx")
+        .hasMessage("invalid change status: nx");
+    assertThatQueryException("status:newx")
+        .hasMessage("invalid change status: newx");
   }
 
   @Test
@@ -619,6 +622,27 @@
     assertQuery("label:Code-Review=+1,user=user", reviewPlus1Change);
     assertQuery("label:Code-Review=+1,Administrators", reviewPlus1Change);
     assertQuery("label:Code-Review=+1,group=Administrators", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=owner", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,owner", reviewPlus1Change);
+    assertQuery("label:Code-Review=+2,owner", reviewPlus2Change);
+    assertQuery("label:Code-Review=-2,owner", reviewMinus2Change);
+  }
+
+  @Test
+  public void byLabelNotOwner() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins = newChange(repo, null, null, null, null);
+    Account.Id user1 = createAccount("user1");
+
+    Change reviewPlus1Change = insert(repo, ins);
+
+    // post a review with user1
+    requestContext.setContext(newRequestContext(user1));
+    gApi.changes().id(reviewPlus1Change.getId().get()).current()
+        .review(ReviewInput.recommend());
+
+    assertQuery("label:Code-Review=+1,user=user1", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,owner");
   }
 
   private Change[] codeReviewInRange(Map<Integer, Change> changes, int start,
@@ -743,7 +767,8 @@
     assertQuery(query, change);
     assertQuery(query.withStart(1));
     assertQuery(query.withStart(99));
-    assertBadQuery(query.withStart(100));
+    assertThatQueryException(query.withStart(100))
+        .hasMessage("Cannot go beyond page 10 of results");
     assertQuery(query.withLimit(100).withStart(100));
   }
 
@@ -1417,6 +1442,41 @@
   }
 
   @Test
+  public void submitRecords() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    gApi.changes()
+        .id(change1.getId().get())
+        .current()
+        .review(ReviewInput.approve());
+    requestContext.setContext(newRequestContext(user1));
+    gApi.changes()
+        .id(change2.getId().get())
+        .current()
+        .review(ReviewInput.recommend());
+    requestContext.setContext(newRequestContext(user.getAccountId()));
+
+    assertQuery("is:submittable", change1);
+    assertQuery("-is:submittable", change2);
+    assertQuery("submittable:ok", change1);
+    assertQuery("submittable:not_ready", change2);
+
+    assertQuery("label:CodE-RevieW=ok", change1);
+    assertQuery("label:CodE-RevieW=ok,user=user", change1);
+    assertQuery("label:CodE-RevieW=ok,Administrators", change1);
+    assertQuery("label:CodE-RevieW=ok,group=Administrators", change1);
+    assertQuery("label:CodE-RevieW=ok,owner", change1);
+    assertQuery("label:CodE-RevieW=ok,user1");
+    assertQuery("label:CodE-RevieW=need", change2);
+    // NEED records don't have associated users.
+    assertQuery("label:CodE-RevieW=need,user1");
+    assertQuery("label:CodE-RevieW=need,user");
+  }
+
+  @Test
   public void byCommitsOnBranchNotMerged() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     int n = 10;
@@ -1436,13 +1496,8 @@
     for (int i = 1; i <= 11; i++) {
       Iterable<ChangeData> cds = internalChangeQuery.byCommitsOnBranchNotMerged(
           repo.getRepository(), db, dest, shas, i);
-      Iterable<Integer> ids = FluentIterable.from(cds).transform(
-          new Function<ChangeData, Integer>() {
-            @Override
-            public Integer apply(ChangeData in) {
-              return in.getId().get();
-            }
-          });
+      Iterable<Integer> ids = FluentIterable.from(cds)
+          .transform(in -> in.getId().get());
       String name = "limit " + i;
       assertThat(ids).named(name).hasSize(n);
       assertThat(ids).named(name)
@@ -1583,7 +1638,7 @@
 
     PatchSetInserter inserter = patchSetFactory.create(
           ctl, new PatchSet.Id(c.getId(), n), commit)
-        .setSendMail(false)
+        .setNotify(NotifyHandling.NONE)
         .setFireRevisionCreated(false)
         .setValidatePolicy(CommitValidators.Policy.NONE);
     try (BatchUpdate bu = updateFactory.create(
@@ -1597,16 +1652,19 @@
     return inserter.getChange();
   }
 
-  protected void assertBadQuery(Object query) throws Exception {
-    assertBadQuery(newQuery(query));
+  protected ThrowableSubject assertThatQueryException(Object query)
+      throws Exception {
+    return assertThatQueryException(newQuery(query));
   }
 
-  protected void assertBadQuery(QueryRequest query) throws Exception {
+  protected ThrowableSubject assertThatQueryException(QueryRequest query)
+      throws Exception {
     try {
       query.get();
-      fail("expected BadRequestException for query: " + query);
+      throw new AssertionError(
+          "expected BadRequestException for query: " + query);
     } catch (BadRequestException e) {
-      // Expected.
+      return assertThat(e);
     }
   }
 
@@ -1639,24 +1697,22 @@
     StringBuilder b = new StringBuilder();
     b.append("query '").append(query.getQuery())
      .append("' with expected changes ");
-    b.append(format(Iterables.transform(Arrays.asList(expectedChanges),
-        new Function<Change, Integer>() {
-          @Override
-          public Integer apply(Change change) {
-            return change.getChangeId();
-          }
-        })));
+    b.append(format(
+        Arrays.stream(expectedChanges).map(Change::getChangeId).iterator()));
     b.append(" and result ");
     b.append(format(actualIds));
     return b.toString();
   }
 
   private String format(Iterable<Integer> changeIds) throws RestApiException {
+    return format(changeIds.iterator());
+  }
+
+  private String format(Iterator<Integer> changeIds) throws RestApiException {
     StringBuilder b = new StringBuilder();
     b.append("[");
-    Iterator<Integer> it = changeIds.iterator();
-    while (it.hasNext()) {
-      int id = it.next();
+    while (changeIds.hasNext()) {
+      int id = changeIds.next();
       ChangeInfo c = gApi.changes().id(id).get();
       b.append("{").append(id).append(" (").append(c.changeId)
           .append("), ").append("dest=").append(
@@ -1665,7 +1721,7 @@
           .append("status=").append(c.status).append(", ")
           .append("lastUpdated=").append(c.updated.getTime())
           .append("}");
-      if (it.hasNext()) {
+      if (changeIds.hasNext()) {
         b.append(", ");
       }
     }
@@ -1674,23 +1730,13 @@
   }
 
   protected static Iterable<Integer> ids(Change... changes) {
-    return FluentIterable.from(Arrays.asList(changes)).transform(
-        new Function<Change, Integer>() {
-          @Override
-          public Integer apply(Change in) {
-            return in.getId().get();
-          }
-        });
+    return FluentIterable.from(Arrays.asList(changes))
+        .transform(in -> in.getId().get());
   }
 
   protected static Iterable<Integer> ids(Iterable<ChangeInfo> changes) {
-    return FluentIterable.from(changes).transform(
-        new Function<ChangeInfo, Integer>() {
-          @Override
-          public Integer apply(ChangeInfo in) {
-            return in._number;
-          }
-        });
+    return FluentIterable.from(changes)
+        .transform(in -> in._number);
   }
 
   protected static long lastUpdatedMs(Change c) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
index cd6e825..bd01be3 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.server.schema;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertTrue;
+import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
@@ -82,7 +79,7 @@
     try (JdbcSchema d = (JdbcSchema) db.open();
         ResultSet rs = d.getConnection().getMetaData()
           .getTables(null, null, null, types)) {
-      assertFalse(rs.next());
+      assertThat(rs.next()).isFalse();
     }
 
     // Create the schema using the current schema version.
@@ -96,7 +93,8 @@
     if (sitePath.getName().equals(".")) {
       sitePath = sitePath.getParentFile();
     }
-    assertEquals(sitePath.getCanonicalPath(), db.getSystemConfig().sitePath);
+    assertThat(db.getSystemConfig().sitePath)
+        .isEqualTo(sitePath.getCanonicalPath());
   }
 
   private LabelTypes getLabelTypes() throws Exception {
@@ -115,27 +113,27 @@
     for (LabelType label : getLabelTypes().getLabelTypes()) {
       labels.add(label.getName());
     }
-    assertEquals(ImmutableList.of("Code-Review"), labels);
+    assertThat(labels).containsExactly("Code-Review");
   }
 
   @Test
   public void testCreateSchema_Label_CodeReview() throws Exception {
     LabelType codeReview = getLabelTypes().byLabel("Code-Review");
-    assertNotNull(codeReview);
-    assertEquals("Code-Review", codeReview.getName());
-    assertEquals(0, codeReview.getDefaultValue());
-    assertEquals("MaxWithBlock", codeReview.getFunctionName());
-    assertTrue(codeReview.isCopyMinScore());
+    assertThat(codeReview).isNotNull();
+    assertThat(codeReview.getName()).isEqualTo("Code-Review");
+    assertThat(codeReview.getDefaultValue()).isEqualTo(0);
+    assertThat(codeReview.getFunctionName()).isEqualTo("MaxWithBlock");
+    assertThat(codeReview.isCopyMinScore()).isTrue();
     assertValueRange(codeReview, 2, 1, 0, -1, -2);
   }
 
   private void assertValueRange(LabelType label, Integer... range) {
-    assertEquals(Arrays.asList(range), label.getValuesAsList());
-    assertEquals(range[0], Integer.valueOf(label.getMax().getValue()));
-    assertEquals(range[range.length - 1],
-      Integer.valueOf(label.getMin().getValue()));
+    assertThat(label.getValuesAsList())
+        .containsExactlyElementsIn(Arrays.asList(range)).inOrder();
+    assertThat(label.getMax().getValue()).isEqualTo(range[0]);
+    assertThat(label.getMin().getValue()).isEqualTo(range[range.length - 1]);
     for (LabelValue v : label.getValues()) {
-      assertFalse(Strings.isNullOrEmpty(v.getText()));
+      assertThat(Strings.isNullOrEmpty(v.getText())).isFalse();
     }
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
index a161405..4b4309a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.schema;
 
-import static org.junit.Assert.assertEquals;
+import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
@@ -36,6 +36,7 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.gwtorm.server.StatementExecutor;
 import com.google.inject.Guice;
+import com.google.inject.ProvisionException;
 import com.google.inject.TypeLiteral;
 
 import org.eclipse.jgit.lib.Config;
@@ -111,6 +112,21 @@
       }
     }).getInstance(SchemaUpdater.class);
 
+    for (SchemaVersion s = u.getLatestSchemaVersion();
+        s.getVersionNbr() > 1; s = s.getPrior()) {
+      try {
+        assertThat(s.getPrior().getVersionNbr())
+            .named("schema %s has prior version %s. Not true that",
+                s.getVersionNbr(), s.getPrior().getVersionNbr())
+            .isEqualTo(s.getVersionNbr() - 1);
+      } catch (ProvisionException e) {
+        // Ignored
+        // The oldest supported schema version doesn't have a prior schema
+        // version.
+        break;
+      }
+    }
+
     u.update(new UpdateUI() {
       @Override
       public void message(String msg) {
@@ -137,6 +153,7 @@
 
     db.assertSchemaVersion();
     final SystemConfig sc = db.getSystemConfig();
-    assertEquals(paths.site_path.toAbsolutePath().toString(), sc.sitePath);
+    assertThat(sc.sitePath)
+        .isEqualTo(paths.site_path.toAbsolutePath().toString());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
index f2d563e..875d43f 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.testutil;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Predicate;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.EmailHeader;
-import com.google.gerrit.server.mail.EmailSender;
+import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -111,17 +111,14 @@
     }
   }
 
-  public ImmutableList<Message> getMessages(String changeId, String type) {
+  public List<Message> getMessages(String changeId, String type) {
     final String idFooter = "\nGerrit-Change-Id: " + changeId + "\n";
     final String typeFooter = "\nGerrit-MessageType: " + type + "\n";
-    return FluentIterable.from(getMessages())
-        .filter(new Predicate<Message>() {
-          @Override
-          public boolean apply(Message in) {
-            return in.body().contains(idFooter)
-                && in.body().contains(typeFooter);
-          }
-        }).toList();
+    return getMessages()
+        .stream()
+        .filter(in -> in.body().contains(idFooter)
+            && in.body().contains(typeFooter))
+        .collect(toList());
   }
 
   private void waitForEmails() {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
index 9e5b776..71401d7 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
@@ -18,11 +18,11 @@
 
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.SitePath;
@@ -50,6 +51,8 @@
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.schema.DataSourceType;
@@ -148,6 +151,8 @@
     // TODO(dborowitz): Use jimfs.
     bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
+    bind(GerritOptions.class)
+        .toInstance(new GerritOptions(cfg, false, false, false));
     bind(PersonIdent.class)
         .annotatedWith(GerritPersonIdent.class)
         .toProvider(GerritPersonIdentProvider.class);
@@ -175,6 +180,7 @@
       .to(InMemoryH2Type.class);
     bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {})
         .to(InMemoryDatabase.class);
+    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
 
     bind(SecureStore.class).to(DefaultSecureStore.class);
 
@@ -214,6 +220,9 @@
         case LUCENE:
           install(luceneIndexModule());
           break;
+        case ELASTICSEARCH:
+          install(elasticIndexModule());
+          break;
         default:
           throw new ProvisionException(
               "index type unsupported in tests: " + indexType);
@@ -236,14 +245,21 @@
   }
 
   private Module luceneIndexModule() {
+    return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
+  }
+
+  private Module elasticIndexModule() {
+    return indexModule("com.google.gerrit.elasticsearch.ElasticIndexModule");
+  }
+
+  private Module indexModule(String moduleClassName) {
     try {
       Map<String, Integer> singleVersions = new HashMap<>();
       int version = cfg.getInt("index", "lucene", "testVersion", -1);
       if (version > 0) {
         singleVersions.put(ChangeSchemaDefinitions.INSTANCE.getName(), version);
       }
-      Class<?> clazz =
-          Class.forName("com.google.gerrit.lucene.LuceneIndexModule");
+      Class<?> clazz = Class.forName(moduleClassName);
       Method m = clazz.getMethod(
           "singleVersionWithExplicitVersions", Map.class, int.class);
       return (Module) m.invoke(null, singleVersions, 0);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
index 61bfe78..c5f4301 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
@@ -15,19 +15,24 @@
 package com.google.gerrit.testutil;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeRebuilder;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gwtorm.client.IntKey;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -40,6 +45,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.stream.Stream;
 
 @Singleton
 public class NoteDbChecker {
@@ -48,37 +54,38 @@
   private final Provider<ReviewDb> dbProvider;
   private final GitRepositoryManager repoManager;
   private final TestNotesMigration notesMigration;
+  private final ChangeBundleReader bundleReader;
   private final ChangeNotes.Factory notesFactory;
   private final ChangeRebuilder changeRebuilder;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   @Inject
   NoteDbChecker(Provider<ReviewDb> dbProvider,
       GitRepositoryManager repoManager,
       TestNotesMigration notesMigration,
+      ChangeBundleReader bundleReader,
       ChangeNotes.Factory notesFactory,
       ChangeRebuilder changeRebuilder,
-      PatchLineCommentsUtil plcUtil) {
+      CommentsUtil commentsUtil) {
     this.dbProvider = dbProvider;
     this.repoManager = repoManager;
+    this.bundleReader = bundleReader;
     this.notesMigration = notesMigration;
     this.notesFactory = notesFactory;
     this.changeRebuilder = changeRebuilder;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
   public void rebuildAndCheckAllChanges() throws Exception {
     rebuildAndCheckChanges(
-        Iterables.transform(
-            getUnwrappedDb().changes().all(),
-            ReviewDbUtil.changeIdFunction()));
+        getUnwrappedDb().changes().all().toList().stream().map(Change::getId));
   }
 
   public void rebuildAndCheckChanges(Change.Id... changeIds) throws Exception {
-    rebuildAndCheckChanges(Arrays.asList(changeIds));
+    rebuildAndCheckChanges(Arrays.stream(changeIds));
   }
 
-  public void rebuildAndCheckChanges(Iterable<Change.Id> changeIds)
+  private void rebuildAndCheckChanges(Stream<Change.Id> changeIds)
       throws Exception {
     ReviewDb db = getUnwrappedDb();
 
@@ -107,11 +114,7 @@
   }
 
   public void checkChanges(Change.Id... changeIds) throws Exception {
-    checkChanges(Arrays.asList(changeIds));
-  }
-
-  public void checkChanges(Iterable<Change.Id> changeIds) throws Exception {
-    checkActual(readExpected(changeIds), new ArrayList<String>());
+    checkActual(readExpected(Arrays.stream(changeIds)), new ArrayList<>());
   }
 
   public void assertNoChangeRef(Project.NameKey project, Change.Id changeId)
@@ -121,24 +124,26 @@
     }
   }
 
-  private List<ChangeBundle> readExpected(Iterable<Change.Id> changeIds)
+  private List<ChangeBundle> readExpected(Stream<Change.Id> changeIds)
       throws Exception {
-    ReviewDb db = getUnwrappedDb();
     boolean old = notesMigration.readChanges();
     try {
       notesMigration.setReadChanges(false);
-      List<Change.Id> sortedIds =
-          ReviewDbUtil.intKeyOrdering().sortedCopy(changeIds);
-      List<ChangeBundle> expected = new ArrayList<>(sortedIds.size());
-      for (Change.Id id : sortedIds) {
-        expected.add(ChangeBundle.fromReviewDb(db, id));
-      }
-      return expected;
+      return changeIds.sorted(comparing(IntKey::get))
+          .map(this::readBundleUnchecked).collect(toList());
     } finally {
       notesMigration.setReadChanges(old);
     }
   }
 
+  private ChangeBundle readBundleUnchecked(Change.Id id) {
+    try {
+      return bundleReader.fromReviewDb(getUnwrappedDb(), id);
+    } catch (OrmException e) {
+      throw new OrmRuntimeException(e);
+    }
+  }
+
   private void checkActual(List<ChangeBundle> allExpected, List<String> msgs)
       throws Exception {
     ReviewDb db = getUnwrappedDb();
@@ -152,7 +157,7 @@
         ChangeBundle actual;
         try {
           actual = ChangeBundle.fromNotes(
-              plcUtil, notesFactory.create(db, c.getProject(), c.getId()));
+              commentsUtil, notesFactory.create(db, c.getProject(), c.getId()));
         } catch (Throwable t) {
           String msg = "Error converting change: " + c;
           msgs.add(msg);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
index 103fee3..23950d4 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.testutil;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.common.base.Enums;
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 
@@ -47,12 +48,10 @@
       return OFF;
     }
     value = value.toUpperCase().replace("-", "_");
-    Optional<NoteDbMode> mode = Enums.getIfPresent(NoteDbMode.class, value);
-    if (!mode.isPresent()) {
-      throw new IllegalArgumentException(
-          "Invalid value for " + VAR + ": " + System.getenv(VAR));
-    }
-    return mode.get();
+    NoteDbMode mode = Enums.getIfPresent(NoteDbMode.class, value).orNull();
+    checkArgument(mode != null,
+        "Invalid value for %s: %s", VAR, System.getenv(VAR));
+    return mode;
   }
 
   public static boolean readWrite() {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
index 4c71c57..efb2b19 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
@@ -22,6 +22,7 @@
 import org.joda.time.DateTimeUtils.MillisProvider;
 import org.joda.time.DateTimeZone;
 
+import java.sql.Timestamp;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 
@@ -65,8 +66,31 @@
     });
   }
 
+  /**
+   * Set the clock to a specific timestamp.
+   *
+   * @param ts time to set
+   */
+  public static synchronized void setClock(Timestamp ts) {
+    checkState(clockMs != null, "call resetWithClockStep first");
+    clockMs.set(ts.getTime());
+  }
+
+  /**
+   * Increment the clock once by a given amount.
+   *
+   * @param clockStep amount to increment clock by.
+   * @param clockStepUnit time unit for {@code clockStep}.
+   */
+  public static synchronized void incrementClock(
+      long clockStep, TimeUnit clockStepUnit) {
+    checkState(clockMs != null, "call resetWithClockStep first");
+    clockMs.addAndGet(clockStepUnit.toMillis(clockStep));
+  }
+
   /** Reset the clock to use the actual system clock. */
   public static synchronized void useSystemTime() {
+    clockMs = null;
     DateTimeUtils.setCurrentMillisSystem();
   }
 
diff --git a/gerrit-sshd/BUCK b/gerrit-sshd/BUCK
index 54b83e2..fcb844f 100644
--- a/gerrit-sshd/BUCK
+++ b/gerrit-sshd/BUCK
@@ -56,5 +56,4 @@
     '//lib:truth',
     '//lib/mina:sshd',
   ],
-  source_under_test = [':sshd'],
 )
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
index fde3a66..4ddca0c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
@@ -97,7 +97,7 @@
       try {
         cmd.destroy();
       } catch (Exception e) {
-        Throwables.propagateIfPossible(e);
+        Throwables.throwIfUnchecked(e);
         throw new RuntimeException(e);
       }
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
index 25fb7a7..3e31fab 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
@@ -385,14 +385,6 @@
     }
   }
 
-  public void checkExclusivity(final Object arg1, final String arg1name,
-      final Object arg2, final String arg2name) throws UnloggedFailure {
-    if (arg1 != null && arg2 != null) {
-      throw new UnloggedFailure(String.format(
-          "%s and %s options are mutually exclusive.", arg1name, arg2name));
-    }
-  }
-
   private final class TaskThunk implements CancelableRunnable, ProjectRunnable {
     private final CommandRunnable thunk;
     private final String taskName;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 7f95471..d658b7f 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -14,9 +14,8 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.common.base.Function;
-import com.google.common.base.Predicates;
-import com.google.common.collect.FluentIterable;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -36,6 +35,7 @@
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 public class ChangeArgumentParser {
   private final CurrentUser currentUser;
@@ -95,24 +95,21 @@
     changes.put(ctl.getId(), changesCollection.parse(ctl));
   }
 
-  private List<ChangeControl> changeFromNotesFactory(String id,
-      final CurrentUser currentUser) throws OrmException {
-    List<ChangeNotes> changes =
-        changeNotesFactory.create(db, Arrays.asList(Change.Id.parse(id)));
-    return FluentIterable.from(changes)
-        .transform(new Function<ChangeNotes, ChangeControl>() {
-          @Override
-          public ChangeControl apply(ChangeNotes changeNote) {
-            return controlForChange(changeNote, currentUser);
-          }
-        }).filter(Predicates.notNull()).toList();
+  private List<ChangeControl> changeFromNotesFactory(String id, CurrentUser currentUser)
+      throws OrmException {
+    return changeNotesFactory.create(db, Arrays.asList(Change.Id.parse(id)))
+        .stream()
+        .map(changeNote -> controlForChange(changeNote, currentUser))
+        .filter(changeControl -> changeControl.isPresent())
+        .map(changeControl -> changeControl.get())
+        .collect(toList());
   }
 
-  private ChangeControl controlForChange(ChangeNotes change, CurrentUser user) {
+  private Optional<ChangeControl> controlForChange(ChangeNotes change, CurrentUser user) {
     try {
-      return changeControlFactory.controlFor(change, user);
+      return Optional.of(changeControlFactory.controlFor(change, user));
     } catch (NoSuchChangeException e) {
-      return null;
+      return Optional.empty();
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
index f2911dc..f3243c6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -136,7 +136,7 @@
       try {
         cmd.destroy();
       } catch (Exception e) {
-        Throwables.propagateIfPossible(e);
+        Throwables.throwIfUnchecked(e);
         throw new RuntimeException(e);
       }
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
index 24bd8c2..c88a02c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
@@ -153,7 +153,7 @@
       try {
         cmd.destroy();
       } catch (Exception e) {
-        Throwables.propagateIfPossible(e);
+        Throwables.throwIfUnchecked(e);
         throw new RuntimeException(e);
       }
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
index eb0d7b2..bd5e9f3 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.common.base.Function;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -200,15 +197,11 @@
     return childProjects;
   }
 
-  private Set<Project.NameKey> getAllParents(final Project.NameKey projectName) {
+  private Set<Project.NameKey> getAllParents(Project.NameKey projectName) {
     ProjectState ps = projectCache.get(projectName);
-    return ImmutableSet.copyOf(Iterables.transform(
-      ps != null ? ps.parents() : Collections.<ProjectState> emptySet(),
-      new Function<ProjectState, Project.NameKey> () {
-        @Override
-        public Project.NameKey apply(ProjectState in) {
-          return in.getProject().getNameKey();
-        }
-      }));
+    if (ps == null) {
+      return Collections.emptySet();
+    }
+    return ps.parents().transform(s -> s.getProject().getNameKey()).toSet();
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
index f78b4df..e15a792 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -56,14 +55,8 @@
   @Override
   protected void run() throws Failure {
     try {
-      BanCommit.Input input =
-          BanCommit.Input.fromCommits(Lists.transform(commitsToBan,
-              new Function<ObjectId, String>() {
-                @Override
-                public String apply(ObjectId oid) {
-                  return oid.getName();
-                }
-              }));
+      BanCommit.Input input = BanCommit.Input.fromCommits(
+          Lists.transform(commitsToBan, ObjectId::getName));
       input.reason = reason;
 
       BanResultInfo r = banCommit.apply(new ProjectResource(projectControl), input);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index d3ff06f..4ecf284 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -16,7 +16,6 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -74,12 +73,7 @@
     input.name = fullName;
     input.sshKey = readSshKey();
     input.httpPassword = httpPassword;
-    input.groups = Lists.transform(groups, new Function<AccountGroup.Id, String>() {
-      @Override
-      public String apply(AccountGroup.Id id) {
-        return id.toString();
-      }
-    });
+    input.groups = Lists.transform(groups, AccountGroup.Id::toString);
     try {
       createAccountFactory.create(username).apply(TopLevelResource.INSTANCE, input);
     } catch (RestApiException e) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 22f9683..f9fd1a9 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -123,30 +123,15 @@
 
   private void addMembers(GroupResource rsrc) throws RestApiException,
       OrmException, IOException {
-    AddMembers.Input input =
-        AddMembers.Input.fromMembers(FluentIterable
-            .from(initialMembers)
-            .transform(new Function<Account.Id, String>() {
-              @Override
-              public String apply(Account.Id id) {
-                return String.valueOf(id.get());
-              }
-            })
-            .toList());
+    AddMembers.Input input = AddMembers.Input.fromMembers(
+        initialMembers.stream().map(Object::toString).collect(toList()));
     addMembers.apply(rsrc, input);
   }
 
   private void addIncludedGroups(GroupResource rsrc) throws RestApiException,
       OrmException {
-    AddIncludedGroups.Input input =
-        AddIncludedGroups.Input.fromGroups(FluentIterable.from(initialGroups)
-            .transform(new Function<AccountGroup.UUID, String>() {
-              @Override
-              public String apply(AccountGroup.UUID id) {
-                return id.get();
-              }
-            }).toList());
-
+    AddIncludedGroups.Input input = AddIncludedGroups.Input.fromGroups(
+        initialGroups.stream().map(AccountGroup.UUID::get).collect(toList()));
     addIncludedGroups.apply(rsrc, input);
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index db4f313..3ef3309 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -140,13 +139,7 @@
         ProjectInput input = new ProjectInput();
         input.name = projectName;
         if (ownerIds != null) {
-          input.owners = Lists.transform(ownerIds,
-            new Function<AccountGroup.UUID, String>() {
-              @Override
-              public String apply(AccountGroup.UUID uuid) {
-                return uuid.get();
-              }
-            });
+          input.owners = Lists.transform(ownerIds, AccountGroup.UUID::get);
         }
         if (newParent != null) {
           input.parent = newParent.getProject().getName();
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index 79e74d7..893c8f2 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.common.base.Function;
-import com.google.common.base.Joiner;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.base.MoreObjects;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -115,55 +114,37 @@
   private void reportMembersAction(String action, GroupResource group,
       List<Account.Id> accountIdList) throws UnsupportedEncodingException,
       IOException {
-    out.write(String.format(
-        "Members %s group %s: %s\n",
-        action,
-        group.getName(),
-        Joiner.on(", ").join(
-            Iterables.transform(accountIdList,
-                new Function<Account.Id, String>() {
-                  @Override
-                  public String apply(Account.Id accountId) {
-                    return MoreObjects.firstNonNull(accountCache.get(accountId)
-                        .getAccount().getPreferredEmail(), "n/a");
-                  }
-                }))).getBytes(ENC));
+    String names = accountIdList.stream()
+        .map(accountId ->
+            MoreObjects.firstNonNull(
+                accountCache.get(accountId).getAccount().getPreferredEmail(),
+                "n/a"))
+        .collect(joining(", "));
+    out.write(
+        String.format(
+                "Members %s group %s: %s\n", action, group.getName(), names)
+            .getBytes(ENC));
   }
 
   private void reportGroupsAction(String action, GroupResource group,
       List<AccountGroup.UUID> groupUuidList)
       throws UnsupportedEncodingException, IOException {
-    out.write(String.format(
-        "Groups %s group %s: %s\n",
-        action,
-        group.getName(),
-        Joiner.on(", ").join(
-            Iterables.transform(groupUuidList,
-                new Function<AccountGroup.UUID, String>() {
-                  @Override
-                  public String apply(AccountGroup.UUID uuid) {
-                    return groupCache.get(uuid).getName();
-                  }
-                }))).getBytes(ENC));
+    String names = groupUuidList.stream()
+      .map(uuid -> groupCache.get(uuid).getName())
+      .collect(joining(", "));
+    out.write(
+        String.format(
+                "Groups %s group %s: %s\n", action, group.getName(), names)
+            .getBytes(ENC));
   }
 
   private AddIncludedGroups.Input fromGroups(List<AccountGroup.UUID> accounts) {
-    return AddIncludedGroups.Input.fromGroups(Lists.newArrayList(Iterables
-        .transform(accounts, new Function<AccountGroup.UUID, String>() {
-          @Override
-          public String apply(AccountGroup.UUID uuid) {
-            return uuid.toString();
-          }
-        })));
+    return AddIncludedGroups.Input.fromGroups(
+        accounts.stream().map(Object::toString).collect(toList()));
   }
 
   private AddMembers.Input fromMembers(List<Account.Id> accounts) {
-    return AddMembers.Input.fromMembers(Lists.newArrayList(Iterables.transform(
-        accounts, new Function<Account.Id, String>() {
-          @Override
-          public String apply(Account.Id id) {
-            return id.toString();
-          }
-        })));
+    return AddMembers.Input.fromMembers(
+        accounts.stream().map(Object::toString).collect(toList()));
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index ac64803..bffb114 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -111,7 +112,7 @@
       ReviewerResource rsrc = reviewerFactory.create(changeRsrc, reviewer);
       String error = null;
       try {
-        deleteReviewer.apply(rsrc, new DeleteReviewer.Input());
+        deleteReviewer.apply(rsrc, new DeleteReviewerInput());
       } catch (ResourceNotFoundException e) {
         error = String.format("could not remove %s: not found", reviewer);
       } catch (Exception e) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 0edba4f..99ced68 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -18,8 +18,8 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.AllowedFormats;
 import com.google.gerrit.server.change.ArchiveFormat;
-import com.google.gerrit.server.change.GetArchive;
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.inject.Inject;
 
@@ -101,7 +101,7 @@
   }
 
   @Inject
-  private GetArchive.AllowedFormats allowedFormats;
+  private AllowedFormats allowedFormats;
   @Inject
   private ReviewDb db;
   private Options options = new Options();
diff --git a/gerrit-util-http/BUCK b/gerrit-util-http/BUCK
index cfab096..79ef836 100644
--- a/gerrit-util-http/BUCK
+++ b/gerrit-util-http/BUCK
@@ -34,7 +34,6 @@
     '//lib:truth',
     '//lib/easymock:easymock',
   ],
-  source_under_test = [':http'],
   # TODO(sop) Remove after Buck supports Eclipse
   visibility = ['//tools/eclipse:classpath'],
 )
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index 3991b95..34f2672 100644
--- a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -18,7 +18,6 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Function;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
@@ -143,18 +142,12 @@
     return Iterables.getFirst(parameters.get(name), null);
   }
 
-  private static final Function<Collection<String>, String[]> STRING_COLLECTION_TO_ARRAY =
-      new Function<Collection<String>, String[]>() {
-        @Override
-        public String[] apply(Collection<String> values) {
-          return values.toArray(new String[0]);
-        }
-      };
-
   @Override
   public Map<String, String[]> getParameterMap() {
     return Collections.unmodifiableMap(
-        Maps.transformValues(parameters.asMap(), STRING_COLLECTION_TO_ARRAY));
+        Maps.transformValues(
+            parameters.asMap(),
+            vs -> vs.toArray(new String[0])));
   }
 
   @Override
@@ -164,7 +157,7 @@
 
   @Override
   public String[] getParameterValues(String name) {
-    return STRING_COLLECTION_TO_ARRAY.apply(parameters.get(name));
+    return parameters.get(name).toArray(new String[0]);
   }
 
   public void setQueryString(String qs) {
diff --git a/gerrit-war/BUCK b/gerrit-war/BUCK
index 6d74a83..5dd1b04 100644
--- a/gerrit-war/BUCK
+++ b/gerrit-war/BUCK
@@ -5,6 +5,7 @@
   srcs = glob(['src/main/java/**/*.java']),
   deps = [
     '//gerrit-cache-h2:cache-h2',
+    '//gerrit-elasticsearch:elasticsearch',
     '//gerrit-extension-api:api',
     '//gerrit-gpg:gpg',
     '//gerrit-httpd:httpd',
diff --git a/gerrit-war/BUILD b/gerrit-war/BUILD
new file mode 100644
index 0000000..8f4946a
--- /dev/null
+++ b/gerrit-war/BUILD
@@ -0,0 +1,71 @@
+load('//tools/bzl:genrule2.bzl', 'genrule2')
+
+java_library(
+  name = 'init',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = [
+    '//gerrit-cache-h2:cache-h2',
+    '//gerrit-elasticsearch:elasticsearch',
+    '//gerrit-extension-api:api',
+    '//gerrit-gpg:gpg',
+    '//gerrit-httpd:httpd',
+    '//gerrit-lucene:lucene',
+    '//gerrit-oauth:oauth',
+    '//gerrit-openid:openid',
+    '//gerrit-pgm:http',
+    '//gerrit-pgm:init',
+    '//gerrit-pgm:init-api',
+    '//gerrit-pgm:util',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//gerrit-server/src/main/prolog:common',
+    '//gerrit-sshd:sshd',
+    '//lib:guava',
+    '//lib:gwtorm',
+    '//lib:servlet-api-3_1',
+    '//lib/guice:guice',
+    '//lib/guice:guice-servlet',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/log:api',
+  ],
+  visibility = ['//visibility:public'],
+)
+
+genrule2(
+  name = 'webapp_assets',
+  cmd = 'cd gerrit-war/src/main/webapp; zip -qr $$ROOT/$@ .',
+  srcs = glob(['src/main/webapp/**/*']),
+  outs = [ 'webapp_assets.zip' ],
+  visibility = ['//visibility:public'],
+)
+
+java_import(
+  name = 'log4j-config',
+  jars = [':log4j-config__jar'],
+  visibility = ['//visibility:public'],
+)
+
+genrule2(
+  name = 'log4j-config__jar',
+  cmd = 'cd gerrit-war/src/main/resources && zip -9Dqr $$ROOT/$@ .',
+  srcs = ['src/main/resources/log4j.properties'],
+  outs = [ 'log4j-config.jar' ],
+)
+
+java_import(
+  name = 'version',
+  jars = [':gen_version'],
+  visibility = ['//visibility:public'],
+)
+
+genrule2(
+  name = 'gen_version',
+  cmd = ' && '.join([
+    'cd $$TMP',
+    'mkdir -p com/google/gerrit/common',
+    'cat $$ROOT/$(location //:version.txt) >com/google/gerrit/common/Version',
+    'zip -9Dqr $$ROOT/$@ .',
+  ]),
+  tools = ['//:version.txt'],
+  outs = [ 'gen_version.jar' ],
+)
diff --git a/gerrit-war/pom.xml b/gerrit-war/pom.xml
index c35a008..acc9b86 100644
--- a/gerrit-war/pom.xml
+++ b/gerrit-war/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>2.13.2</version>
+  <version>2.14-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
index 71eed63..25aa5bc 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
@@ -19,6 +19,8 @@
 
 import com.google.common.base.Splitter;
 import com.google.gerrit.common.EventBroker;
+import com.google.gerrit.elasticsearch.ElasticIndexModule;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
@@ -29,7 +31,6 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.util.LogFileCompressor;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
@@ -38,6 +39,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.RestCacheAdminModule;
@@ -51,7 +53,7 @@
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
-import com.google.gerrit.server.mail.SmtpEmailSender;
+import com.google.gerrit.server.mail.send.SmtpEmailSender;
 import com.google.gerrit.server.mime.MimeUtil2Module;
 import com.google.gerrit.server.notedb.ConfigNotesMigration;
 import com.google.gerrit.server.patch.DiffExecutorModule;
@@ -342,6 +344,8 @@
     switch (indexType) {
       case LUCENE:
         return LuceneIndexModule.latestVersionWithOnlineUpgrade();
+      case ELASTICSEARCH:
+        return ElasticIndexModule.latestVersionWithOnlineUpgrade();
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
     }
diff --git a/lib/BUCK b/lib/BUCK
index 380a3ef..c01d75a 100644
--- a/lib/BUCK
+++ b/lib/BUCK
@@ -17,6 +17,7 @@
 define_license(name = 'fetch')
 define_license(name = 'h2')
 define_license(name = 'highlightjs')
+define_license(name = 'icu4j')
 define_license(name = 'jgit')
 define_license(name = 'jsch')
 define_license(name = 'MPL1.1')
@@ -38,9 +39,9 @@
 
 maven_jar(
   name = 'gwtorm_client',
-  id = 'com.google.gerrit:gwtorm:1.15',
-  bin_sha1 = '26a2459f543ed78977535f92e379dc0d6cdde8bb',
-  src_sha1 = '9524088d6e46e299b12791cb1a63c4ba6a478b96',
+  id = 'com.google.gerrit:gwtorm:1.16',
+  bin_sha1 = '3e41b6d7bb352fa0539ce23b9bce97cf8c26c3bf',
+  src_sha1 = 'f45b7bacc79a0e5a7f6cf799a2dba23cc5bca19b',
   license = 'Apache2.0',
 )
 
@@ -53,9 +54,9 @@
 
 maven_jar(
   name = 'gwtjsonrpc',
-  id = 'com.google.gerrit:gwtjsonrpc:1.9',
-  bin_sha1 = '458f55e92584fbd9ab91a89fa1c37654922a0f2b',
-  src_sha1 = 'ba539361c80a26f0d30a2f56068f6d83f44062d8',
+  id = 'com.google.gerrit:gwtjsonrpc:1.11',
+  bin_sha1 = '0990e7eec9eec3a15661edcf9232acbac4aeacec',
+  src_sha1 = 'a682afc46284fb58197a173cb5818770a1e7834a',
   license = 'Apache2.0',
 )
 
@@ -69,7 +70,7 @@
 maven_jar(
   name = 'guava',
   id = 'com.google.guava:guava:' + GUAVA_VERSION,
-  sha1 = '6ce200f6b23222af3d8abb6b6459e6c44f4bb0e9',
+  sha1 = GUAVA_BIN_SHA1,
   license = 'Apache2.0',
 )
 
@@ -90,7 +91,11 @@
   # Whitelist lib targets that have jsr305 as a dependency. Generally speaking
   # Gerrit core should not depend on these annotations, and instead use
   # equivalent annotations in com.google.gerrit.common.
-  visibility = ['//lib:guava-retrying'],
+  visibility = [
+    '//gerrit-plugin-api:lib',
+    '//lib:guava-retrying',
+    '//lib:soy',
+  ],
 )
 
 maven_jar(
@@ -108,8 +113,8 @@
 
 maven_jar(
   name = 'jsch',
-  id = 'com.jcraft:jsch:0.1.53',
-  sha1 = '658b682d5c817b27ae795637dfec047c63d29935',
+  id = 'com.jcraft:jsch:0.1.54',
+  sha1 = 'da3584329a263616e277e15462b387addd1b208d',
   license = 'jsch',
 )
 
@@ -217,8 +222,8 @@
 
 maven_jar(
   name = 'jimfs',
-  id = 'com.google.jimfs:jimfs:1.0',
-  sha1 = 'edd65a2b792755f58f11134e76485a928aab4c97',
+  id = 'com.google.jimfs:jimfs:1.1',
+  sha1 = '8fbd0579dc68aba6186935cc1bee21d2f3e7ec1c',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [':guava'],
 )
@@ -241,8 +246,8 @@
 
 maven_jar(
   name = 'truth',
-  id = 'com.google.truth:truth:0.28',
-  sha1 = '0a388c7877c845ff4b8e19689dda5ac9d34622c4',
+  id = 'com.google.truth:truth:0.30',
+  sha1 = '9d591b5a66eda81f0b88cf1c748ab8853d99b18b',
   license = 'DO_NOT_DISTRIBUTE',
   exported_deps = [
     ':guava',
@@ -273,3 +278,34 @@
   license = 'Apache2.0',
   repository = GERRIT,
 )
+
+# Keep this version of Soy synchronized with the version used in Gitiles.
+maven_jar(
+  name = 'soy',
+  id = 'com.google.template:soy:2016-08-09',
+  sha1 = '43d33651e95480d515fe26c10a662faafe3ad1e4',
+  license = 'Apache2.0',
+  deps = [
+    ':args4j',
+    ':guava',
+    ':gson',
+    ':icu4j',
+    ':jsr305',
+    ':protobuf',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/guice:multibindings',
+    '//lib/guice:javax-inject',
+    '//lib/ow2:ow2-asm',
+    '//lib/ow2:ow2-asm-analysis',
+    '//lib/ow2:ow2-asm-commons',
+    '//lib/ow2:ow2-asm-util',
+  ],
+)
+
+maven_jar(
+  name = 'icu4j',
+  id = 'com.ibm.icu:icu4j:57.1',
+  sha1 = '198ea005f41219f038f4291f0b0e9f3259730e92',
+  license = 'icu4j',
+)
diff --git a/lib/BUILD b/lib/BUILD
index e89e63c..292560b 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -1,50 +1,68 @@
+exports_files(glob([
+  "LICENSE-*"
+]))
+
+filegroup(
+  name = 'all-licenses',
+  srcs = glob(['LICENSE-*'], exclude = ['LICENSE-DO_NOT_DISTRIBUTE']),
+  visibility = ['//visibility:public'],
+)
+
 java_library(
   name = 'servlet-api-3_1',
   neverlink = 1,
   exports = ['@servlet_api_3_1//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'servlet-api-3_1-without-neverlink',
   exports = ['@servlet_api_3_1//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'gwtjsonrpc',
   exports = ['@gwtjsonrpc//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'gwtjsonrpc_src',
   exports = ['@gwtjsonrpc_src//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'gson',
   exports = ['@gson//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'gwtorm_client',
   exports = ['@gwtorm_client//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'gwtorm_client_src',
   exports = ['@gwtorm_client_src//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'protobuf',
   exports = ['@protobuf//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-protobuf'],
 )
 
 java_library(
@@ -58,6 +76,7 @@
   name = 'guava',
   exports = ['@guava//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -69,30 +88,35 @@
     '//lib/commons:oro',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'jsch',
   exports = ['@jsch//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-jsch'],
 )
 
 java_library(
   name = 'juniversalchardet',
   exports = ['@juniversalchardet//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-MPL1.1'],
 )
 
 java_library(
   name = 'args4j',
   exports = ['@args4j//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-args4j'],
 )
 
 java_library(
   name = 'automaton',
   exports = ['@automaton//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-automaton'],
 )
 
 java_library(
@@ -100,6 +124,7 @@
   exports = ['@pegdown//jar'],
   runtime_deps = [':grappa'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -113,24 +138,28 @@
     '//lib/ow2:ow2-asm-util',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'jitescript',
   exports = ['@jitescript//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'tukaani-xz',
   exports = ['@tukaani_xz//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-xz'],
 )
 
 java_library(
   name = 'mime-util',
   exports = ['@mime_util//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -138,23 +167,28 @@
   exports = ['@guava_retrying//jar'],
   runtime_deps = [':jsr305'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'jsr305',
   exports = ['@jsr305//jar'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'blame-cache',
   exports = ['@blame_cache//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
+
 java_library(
   name = 'h2',
   exports = ['@h2//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-h2'],
 )
 
 
@@ -163,6 +197,7 @@
   exports = ['@jimfs//jar'],
   runtime_deps = [':guava'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
@@ -173,12 +208,14 @@
   ],
   runtime_deps = [':hamcrest-core'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
   name = 'hamcrest-core',
   exports = ['@hamcrest_core//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
@@ -189,16 +226,63 @@
     ':junit',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
   name = 'javassist',
   exports = ['@javassist//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
   name = 'derby',
   exports = ['@derby//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'soy',
+  exports = ['@soy//jar'],
+  runtime_deps = [
+    ':args4j',
+    ':guava',
+    ':gson',
+    ':icu4j',
+    ':jsr305',
+    ':protobuf',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/guice:multibindings',
+    '//lib/guice:javax-inject',
+    '//lib/ow2:ow2-asm',
+    '//lib/ow2:ow2-asm-analysis',
+    '//lib/ow2:ow2-asm-commons',
+    '//lib/ow2:ow2-asm-util',
+  ],
+  visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'icu4j',
+  exports = [ '@icu4j//jar' ],
+  visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-icu4j'],
+)
+
+java_library(
+  name = 'postgresql',
+  exports = ['@postgresql//jar'],
+  visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-postgresql'],
+)
+
+java_library(
+  name = 'commons-io',
+  exports = ['@commons_io//jar'],
+  visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/GUAVA_VERSION b/lib/GUAVA_VERSION
index f889e2b..b5f47b3 100644
--- a/lib/GUAVA_VERSION
+++ b/lib/GUAVA_VERSION
@@ -1,2 +1 @@
-GUAVA_VERSION = '19.0'
-GUAVA_DOC_URL = 'https://google.github.io/guava/releases/' + GUAVA_VERSION + '/api/docs/'
+include_defs('//lib/guava.bzl')
diff --git a/lib/JGIT_VERSION b/lib/JGIT_VERSION
index b7f7c84..569cf59 100644
--- a/lib/JGIT_VERSION
+++ b/lib/JGIT_VERSION
@@ -1,6 +1,4 @@
+include_defs('//lib/jgit/jgit.bzl')
 include_defs('//lib/maven.defs')
 
 REPO = MAVEN_CENTRAL # Leave here even if set to MAVEN_CENTRAL.
-VERS = '4.5.0.201609210915-r'
-DOC_VERS = VERS # Set to VERS unless using a snapshot
-JGIT_DOC_URL="http://download.eclipse.org/jgit/site/" + DOC_VERS + "/apidocs"
diff --git a/lib/LICENSE-icu4j b/lib/LICENSE-icu4j
new file mode 100644
index 0000000..90be7cd
--- /dev/null
+++ b/lib/LICENSE-icu4j
@@ -0,0 +1,385 @@
+COPYRIGHT AND PERMISSION NOTICE (ICU 58 and later)
+
+Copyright © 1991-2016 Unicode, Inc. All rights reserved.
+Distributed under the Terms of Use in http://www.unicode.org/copyright.html
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Unicode data files and any associated documentation
+(the "Data Files") or Unicode software and any associated documentation
+(the "Software") to deal in the Data Files or Software
+without restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, and/or sell copies of
+the Data Files or Software, and to permit persons to whom the Data Files
+or Software are furnished to do so, provided that either
+(a) this copyright and permission notice appear with all copies
+of the Data Files or Software, or
+(b) this copyright and permission notice appear in associated
+Documentation.
+
+THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT OF THIRD PARTY RIGHTS.
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS
+NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL
+DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
+DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THE DATA FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder
+shall not be used in advertising or otherwise to promote the sale,
+use or other dealings in these Data Files or Software without prior
+written authorization of the copyright holder.
+
+---------------------
+
+Third-Party Software Licenses
+
+This section contains third-party software notices and/or additional
+terms for licensed third-party software components included within ICU
+libraries.
+
+1. ICU License - ICU 1.8.1 to ICU 57.1
+
+COPYRIGHT AND PERMISSION NOTICE
+
+Copyright (c) 1995-2016 International Business Machines Corporation and others
+All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, and/or sell copies of the Software, and to permit persons
+to whom the Software is furnished to do so, provided that the above
+copyright notice(s) and this permission notice appear in all copies of
+the Software and that both the above copyright notice(s) and this
+permission notice appear in supporting documentation.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY
+SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER
+RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
+CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder
+shall not be used in advertising or otherwise to promote the sale, use
+or other dealings in this Software without prior written authorization
+of the copyright holder.
+
+All trademarks and registered trademarks mentioned herein are the
+property of their respective owners.
+
+2. Chinese/Japanese Word Break Dictionary Data (cjdict.txt)
+
+ #     The Google Chrome software developed by Google is licensed under
+ # the BSD license. Other software included in this distribution is
+ # provided under other licenses, as set forth below.
+ #
+ #  The BSD License
+ #  http://opensource.org/licenses/bsd-license.php
+ #  Copyright (C) 2006-2008, Google Inc.
+ #
+ #  All rights reserved.
+ #
+ #  Redistribution and use in source and binary forms, with or without
+ # modification, are permitted provided that the following conditions are met:
+ #
+ #  Redistributions of source code must retain the above copyright notice,
+ # this list of conditions and the following disclaimer.
+ #  Redistributions in binary form must reproduce the above
+ # copyright notice, this list of conditions and the following
+ # disclaimer in the documentation and/or other materials provided with
+ # the distribution.
+ #  Neither the name of  Google Inc. nor the names of its
+ # contributors may be used to endorse or promote products derived from
+ # this software without specific prior written permission.
+ #
+ #
+ #  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+ # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ #
+ #
+ #  The word list in cjdict.txt are generated by combining three word lists
+ # listed below with further processing for compound word breaking. The
+ # frequency is generated with an iterative training against Google web
+ # corpora.
+ #
+ #  * Libtabe (Chinese)
+ #    - https://sourceforge.net/project/?group_id=1519
+ #    - Its license terms and conditions are shown below.
+ #
+ #  * IPADIC (Japanese)
+ #    - http://chasen.aist-nara.ac.jp/chasen/distribution.html
+ #    - Its license terms and conditions are shown below.
+ #
+ #  ---------COPYING.libtabe ---- BEGIN--------------------
+ #
+ #  /*
+ #   * Copyrighy (c) 1999 TaBE Project.
+ #   * Copyright (c) 1999 Pai-Hsiang Hsiao.
+ #   * All rights reserved.
+ #   *
+ #   * Redistribution and use in source and binary forms, with or without
+ #   * modification, are permitted provided that the following conditions
+ #   * are met:
+ #   *
+ #   * . Redistributions of source code must retain the above copyright
+ #   *   notice, this list of conditions and the following disclaimer.
+ #   * . Redistributions in binary form must reproduce the above copyright
+ #   *   notice, this list of conditions and the following disclaimer in
+ #   *   the documentation and/or other materials provided with the
+ #   *   distribution.
+ #   * . Neither the name of the TaBE Project nor the names of its
+ #   *   contributors may be used to endorse or promote products derived
+ #   *   from this software without specific prior written permission.
+ #   *
+ #   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ #   * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ #   * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ #   * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ #   * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ #   * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ #   * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ #   * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ #   * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ #   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ #   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ #   * OF THE POSSIBILITY OF SUCH DAMAGE.
+ #   */
+ #
+ #  /*
+ #   * Copyright (c) 1999 Computer Systems and Communication Lab,
+ #   *                    Institute of Information Science, Academia
+ #       *                    Sinica. All rights reserved.
+ #   *
+ #   * Redistribution and use in source and binary forms, with or without
+ #   * modification, are permitted provided that the following conditions
+ #   * are met:
+ #   *
+ #   * . Redistributions of source code must retain the above copyright
+ #   *   notice, this list of conditions and the following disclaimer.
+ #   * . Redistributions in binary form must reproduce the above copyright
+ #   *   notice, this list of conditions and the following disclaimer in
+ #   *   the documentation and/or other materials provided with the
+ #   *   distribution.
+ #   * . Neither the name of the Computer Systems and Communication Lab
+ #   *   nor the names of its contributors may be used to endorse or
+ #   *   promote products derived from this software without specific
+ #   *   prior written permission.
+ #   *
+ #   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ #   * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ #   * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ #   * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ #   * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ #   * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ #   * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ #   * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ #   * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ #   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ #   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ #   * OF THE POSSIBILITY OF SUCH DAMAGE.
+ #   */
+ #
+ #  Copyright 1996 Chih-Hao Tsai @ Beckman Institute,
+ #      University of Illinois
+ #  c-tsai4@uiuc.edu  http://casper.beckman.uiuc.edu/~c-tsai4
+ #
+ #  ---------------COPYING.libtabe-----END--------------------------------
+ #
+ #
+ #  ---------------COPYING.ipadic-----BEGIN-------------------------------
+ #
+ #  Copyright 2000, 2001, 2002, 2003 Nara Institute of Science
+ #  and Technology.  All Rights Reserved.
+ #
+ #  Use, reproduction, and distribution of this software is permitted.
+ #  Any copy of this software, whether in its original form or modified,
+ #  must include both the above copyright notice and the following
+ #  paragraphs.
+ #
+ #  Nara Institute of Science and Technology (NAIST),
+ #  the copyright holders, disclaims all warranties with regard to this
+ #  software, including all implied warranties of merchantability and
+ #  fitness, in no event shall NAIST be liable for
+ #  any special, indirect or consequential damages or any damages
+ #  whatsoever resulting from loss of use, data or profits, whether in an
+ #  action of contract, negligence or other tortuous action, arising out
+ #  of or in connection with the use or performance of this software.
+ #
+ #  A large portion of the dictionary entries
+ #  originate from ICOT Free Software.  The following conditions for ICOT
+ #  Free Software applies to the current dictionary as well.
+ #
+ #  Each User may also freely distribute the Program, whether in its
+ #  original form or modified, to any third party or parties, PROVIDED
+ #  that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear
+ #  on, or be attached to, the Program, which is distributed substantially
+ #  in the same form as set out herein and that such intended
+ #  distribution, if actually made, will neither violate or otherwise
+ #  contravene any of the laws and regulations of the countries having
+ #  jurisdiction over the User or the intended distribution itself.
+ #
+ #  NO WARRANTY
+ #
+ #  The program was produced on an experimental basis in the course of the
+ #  research and development conducted during the project and is provided
+ #  to users as so produced on an experimental basis.  Accordingly, the
+ #  program is provided without any warranty whatsoever, whether express,
+ #  implied, statutory or otherwise.  The term "warranty" used herein
+ #  includes, but is not limited to, any warranty of the quality,
+ #  performance, merchantability and fitness for a particular purpose of
+ #  the program and the nonexistence of any infringement or violation of
+ #  any right of any third party.
+ #
+ #  Each user of the program will agree and understand, and be deemed to
+ #  have agreed and understood, that there is no warranty whatsoever for
+ #  the program and, accordingly, the entire risk arising from or
+ #  otherwise connected with the program is assumed by the user.
+ #
+ #  Therefore, neither ICOT, the copyright holder, or any other
+ #  organization that participated in or was otherwise related to the
+ #  development of the program and their respective officials, directors,
+ #  officers and other employees shall be held liable for any and all
+ #  damages, including, without limitation, general, special, incidental
+ #  and consequential damages, arising out of or otherwise in connection
+ #  with the use or inability to use the program or any product, material
+ #  or result produced or otherwise obtained by using the program,
+ #  regardless of whether they have been advised of, or otherwise had
+ #  knowledge of, the possibility of such damages at any time during the
+ #  project or thereafter.  Each user will be deemed to have agreed to the
+ #  foregoing by his or her commencement of use of the program.  The term
+ #  "use" as used herein includes, but is not limited to, the use,
+ #  modification, copying and distribution of the program and the
+ #  production of secondary products from the program.
+ #
+ #  In the case where the program, whether in its original form or
+ #  modified, was distributed or delivered to or received by a user from
+ #  any person, organization or entity other than ICOT, unless it makes or
+ #  grants independently of ICOT any specific warranty to the user in
+ #  writing, such person, organization or entity, will also be exempted
+ #  from and not be held liable to the user for any such damages as noted
+ #  above as far as the program is concerned.
+ #
+ #  ---------------COPYING.ipadic-----END----------------------------------
+
+3. Lao Word Break Dictionary Data (laodict.txt)
+
+ #  Copyright (c) 2013 International Business Machines Corporation
+ #  and others. All Rights Reserved.
+ #
+ # Project: http://code.google.com/p/lao-dictionary/
+ # Dictionary: http://lao-dictionary.googlecode.com/git/Lao-Dictionary.txt
+ # License: http://lao-dictionary.googlecode.com/git/Lao-Dictionary-LICENSE.txt
+ #              (copied below)
+ #
+ #  This file is derived from the above dictionary, with slight
+ #  modifications.
+ #  ----------------------------------------------------------------------
+ #  Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell.
+ #  All rights reserved.
+ #
+ #  Redistribution and use in source and binary forms, with or without
+ #  modification,
+ #  are permitted provided that the following conditions are met:
+ #
+ #
+ # Redistributions of source code must retain the above copyright notice, this
+ #  list of conditions and the following disclaimer. Redistributions in
+ #  binary form must reproduce the above copyright notice, this list of
+ #  conditions and the following disclaimer in the documentation and/or
+ #  other materials provided with the distribution.
+ #
+ #
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ # OF THE POSSIBILITY OF SUCH DAMAGE.
+ #  --------------------------------------------------------------------------
+
+4. Burmese Word Break Dictionary Data (burmesedict.txt)
+
+ #  Copyright (c) 2014 International Business Machines Corporation
+ #  and others. All Rights Reserved.
+ #
+ #  This list is part of a project hosted at:
+ #    github.com/kanyawtech/myanmar-karen-word-lists
+ #
+ #  --------------------------------------------------------------------------
+ #  Copyright (c) 2013, LeRoy Benjamin Sharon
+ #  All rights reserved.
+ #
+ #  Redistribution and use in source and binary forms, with or without
+ #  modification, are permitted provided that the following conditions
+ #  are met: Redistributions of source code must retain the above
+ #  copyright notice, this list of conditions and the following
+ #  disclaimer.  Redistributions in binary form must reproduce the
+ #  above copyright notice, this list of conditions and the following
+ #  disclaimer in the documentation and/or other materials provided
+ #  with the distribution.
+ #
+ #    Neither the name Myanmar Karen Word Lists, nor the names of its
+ #    contributors may be used to endorse or promote products derived
+ #    from this software without specific prior written permission.
+ #
+ #  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ #  CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ #  INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ #  MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ #  DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS
+ #  BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ #  EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+ #  TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ #  DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ #  ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+ #  TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
+ #  THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ #  SUCH DAMAGE.
+ #  --------------------------------------------------------------------------
+
+5. Time Zone Database
+
+  ICU uses the public domain data and code derived from Time Zone
+Database for its time zone support. The ownership of the TZ database
+is explained in BCP 175: Procedure for Maintaining the Time Zone
+Database section 7.
+
+ # 7.  Database Ownership
+ #
+ #    The TZ database itself is not an IETF Contribution or an IETF
+ #    document.  Rather it is a pre-existing and regularly updated work
+ #    that is in the public domain, and is intended to remain in the
+ #    public domain.  Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do
+ #    not apply to the TZ Database or contributions that individuals make
+ #    to it.  Should any claims be made and substantiated against the TZ
+ #    Database, the organization that is providing the IANA
+ #    Considerations defined in this RFC, under the memorandum of
+ #    understanding with the IETF, currently ICANN, may act in accordance
+ #    with all competent court orders.  No ownership claims will be made
+ #    by ICANN or the IETF Trust on the database or the code.  Any person
+ #    making a contribution to the database or code waives all rights to
+ #    future claims in that contribution or in the TZ Database.
diff --git a/lib/antlr/BUILD b/lib/antlr/BUILD
index ede7665..92f3d0f 100644
--- a/lib/antlr/BUILD
+++ b/lib/antlr/BUILD
@@ -2,6 +2,7 @@
 [java_library(
   name = n,
   exports = ['@%s//jar' % n],
+  data = ['//lib:LICENSE-antlr'],
 ) for n in [
   'antlr27',
   'stringtemplate',
@@ -11,6 +12,7 @@
   name = 'java_runtime',
   exports = ['@java_runtime//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-antlr'],
 )
 
 java_binary(
@@ -28,4 +30,5 @@
     ':java_runtime',
     ':stringtemplate',
   ],
+  data = ['//lib:LICENSE-antlr'],
 )
diff --git a/lib/asciidoctor/BUCK b/lib/asciidoctor/BUCK
index 733c670..5b4cd6b 100644
--- a/lib/asciidoctor/BUCK
+++ b/lib/asciidoctor/BUCK
@@ -53,8 +53,8 @@
 
 maven_jar(
   name = 'jruby',
-  id = 'org.jruby:jruby-complete:1.7.25',
-  sha1 = '8eb234259ec88edc05eedab05655f458a84bfcab',
+  id = 'org.jruby:jruby-complete:9.1.5.0',
+  sha1 = '00d0003e99da3c4d830b12c099691ce910c84e39',
   license = 'DO_NOT_DISTRIBUTE',
   visibility = [],
   attach_source = False,
diff --git a/lib/asciidoctor/BUILD b/lib/asciidoctor/BUILD
new file mode 100644
index 0000000..d1b98f8
--- /dev/null
+++ b/lib/asciidoctor/BUILD
@@ -0,0 +1,54 @@
+java_binary(
+  name = "asciidoc",
+  main_class = "AsciiDoctor",
+  runtime_deps = [":asciidoc_lib"],
+  visibility = ["//visibility:public"],
+)
+
+java_library(
+  name = "asciidoc_lib",
+  srcs = ["java/AsciiDoctor.java"],
+  deps = [
+    ":asciidoctor",
+    "//lib:args4j",
+    "//lib:guava",
+    "//lib/log:api",
+    "//lib/log:nop",
+  ],
+  visibility = ["//visibility:public"],
+)
+
+java_binary(
+  name = "doc_indexer",
+  main_class = "DocIndexer",
+  runtime_deps = [":doc_indexer_lib"],
+  visibility = ["//visibility:public"],
+)
+
+java_library(
+  name = "doc_indexer_lib",
+  srcs = ["java/DocIndexer.java"],
+  deps = [
+    ":asciidoc_lib",
+    "//gerrit-server:constants",
+    "//lib:args4j",
+    "//lib:guava",
+    "//lib/lucene:lucene-analyzers-common",
+    "//lib/lucene:lucene-core-and-backward-codecs",
+  ],
+  visibility = ["//visibility:public"],
+)
+
+java_library(
+  name = "asciidoctor",
+  exports = ["@asciidoctor//jar"],
+  runtime_deps = [":jruby"],
+  visibility = ["//visibility:public"],
+  data = ["//lib:LICENSE-asciidoctor"],
+)
+
+java_library(
+  name = "jruby",
+  exports = ["@jruby//jar"],
+  data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+)
diff --git a/lib/asciidoctor/java/AsciiDoctor.java b/lib/asciidoctor/java/AsciiDoctor.java
index 8e18feb1..1871b0c 100644
--- a/lib/asciidoctor/java/AsciiDoctor.java
+++ b/lib/asciidoctor/java/AsciiDoctor.java
@@ -25,11 +25,14 @@
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.Option;
 
+import java.io.BufferedReader;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
+import java.io.FileReader;
 import java.io.FilenameFilter;
 import java.io.IOException;
+import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -41,6 +44,7 @@
 
   private static final String DOCTYPE = "article";
   private static final String ERUBY = "erb";
+  private static final String REVNUMBER_NAME = "revnumber";
 
   @Option(name = "-b", usage = "set output format backend")
   private String backend = "html5";
@@ -60,13 +64,26 @@
   @Option(name = "--tmp", usage = "temporary output path")
   private File tmpdir;
 
+  @Option(name = "--mktmp", usage = "create a temporary output path")
+  private boolean mktmp;
+
   @Option(name = "-a", usage =
       "a list of attributes, in the form key or key=value pair")
   private List<String> attributes = new ArrayList<>();
 
+  @Option(name = "--bazel", usage =
+      "bazel mode: generate multiple output files instead of a single zip file")
+  private boolean bazel;
+
+  @Option(name = "--revnumber-file", usage =
+      "the file contains revnumber string")
+  private File revnumberFile;
+
   @Argument(usage = "input files")
   private List<String> inputFiles = new ArrayList<>();
 
+  private String revnumber;
+
   public static String mapInFileToOutFile(
       String inFile, String inExt, String outExt) {
     String basename = new File(inFile).getName();
@@ -82,19 +99,26 @@
     return basename + outExt;
   }
 
-  private Options createOptions(File outputFile) {
+  private Options createOptions(File base, File outputFile) {
     OptionsBuilder optionsBuilder = OptionsBuilder.options();
 
-    optionsBuilder.backend(backend).docType(DOCTYPE).eruby(ERUBY)
-      .safe(SafeMode.UNSAFE).baseDir(basedir);
-    // XXX(fishywang): ideally we should just output to a string and add the
-    // content into zip. But asciidoctor will actually ignore all attributes if
-    // not output to a file. So we *have* to output to a file then read the
-    // content of the file into zip.
-    optionsBuilder.toFile(outputFile);
+    optionsBuilder
+        .backend(backend)
+        .docType(DOCTYPE)
+        .eruby(ERUBY)
+        .safe(SafeMode.UNSAFE)
+        .baseDir(base)
+        // XXX(fishywang): ideally we should just output to a string and add the
+        // content into zip. But asciidoctor will actually ignore all attributes
+        // if not output to a file. So we *have* to output to a file then read
+        // the content of the file into zip.
+        .toFile(outputFile);
 
     AttributesBuilder attributesBuilder = AttributesBuilder.attributes();
     attributesBuilder.attributes(getAttributes());
+    if (revnumber != null) {
+      attributesBuilder.attribute(REVNUMBER_NAME, revnumber);
+    }
     optionsBuilder.attributes(attributesBuilder.get());
 
     return optionsBuilder.get();
@@ -133,31 +157,52 @@
       return;
     }
 
-    try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(zipFile))) {
-      for (String inputFile : inputFiles) {
-        if (!inputFile.endsWith(inExt)) {
-          // We have to use UNSAFE mode in order to make embedding work. But in
-          // UNSAFE mode we'll also need css file in the same directory, so we
-          // have to add css files into the SRCS.
-          continue;
-        }
-
-        String outName = mapInFileToOutFile(inputFile, inExt, outExt);
-        File out = new File(tmpdir, outName);
-        out.getParentFile().mkdirs();
-        Options options = createOptions(out);
-        renderInput(options, new File(inputFile));
-        zipFile(out, outName, zip);
+    if (revnumberFile != null) {
+      try (BufferedReader reader =
+          new BufferedReader(new FileReader(revnumberFile))) {
+        revnumber = reader.readLine();
       }
+    }
 
-      File[] cssFiles = tmpdir.listFiles(new FilenameFilter() {
-        @Override
-        public boolean accept(File dir, String name) {
-          return name.endsWith(".css");
+    if (mktmp) {
+      tmpdir = Files.createTempDirectory("asciidoctor-").toFile();
+    }
+
+    if (bazel) {
+      renderFiles(inputFiles, null);
+    } else {
+      try (ZipOutputStream zip =
+          new ZipOutputStream(new FileOutputStream(zipFile))) {
+        renderFiles(inputFiles, zip);
+
+        File[] cssFiles = tmpdir.listFiles(new FilenameFilter() {
+          @Override
+          public boolean accept(File dir, String name) {
+            return name.endsWith(".css");
+          }
+        });
+        for (File css : cssFiles) {
+          zipFile(css, css.getName(), zip);
         }
-      });
-      for (File css : cssFiles) {
-        zipFile(css, css.getName(), zip);
+      }
+    }
+  }
+
+  private void renderFiles(List<String> inputFiles, ZipOutputStream zip)
+      throws IOException {
+    Asciidoctor asciidoctor = JRubyAsciidoctor.create();
+    for (String inputFile : inputFiles) {
+      String outName = mapInFileToOutFile(inputFile, inExt, outExt);
+      File out = bazel ? new File(outName) : new File(tmpdir, outName);
+      if (!bazel) {
+        out.getParentFile().mkdirs();
+      }
+      File input = new File(inputFile);
+      Options options =
+          createOptions(basedir != null ? basedir : input.getParentFile(), out);
+      asciidoctor.renderFile(input, options);
+      if (zip != null) {
+        zipFile(out, outName, zip);
       }
     }
   }
@@ -171,11 +216,6 @@
     zip.closeEntry();
   }
 
-  private void renderInput(Options options, File inputFile) {
-    Asciidoctor asciidoctor = JRubyAsciidoctor.create();
-    asciidoctor.renderFile(inputFile, options);
-  }
-
   public static void main(String[] args) {
     try {
       new AsciiDoctor().invoke(args);
diff --git a/lib/auto/BUCK b/lib/auto/BUCK
index 6197e34..c186f87 100644
--- a/lib/auto/BUCK
+++ b/lib/auto/BUCK
@@ -2,8 +2,8 @@
 
 maven_jar(
   name = 'auto-value',
-  id = 'com.google.auto.value:auto-value:1.3-rc1',
-  sha1 = 'b764e0fb7e11353fbff493b22fd6e83bf091a179',
+  id = 'com.google.auto.value:auto-value:1.4-rc1',
+  sha1 = '9347939002003a7a3c3af48271fc2c18734528a4',
   license = 'Apache2.0',
   visibility = ['PUBLIC'],
 )
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index e07c36d..c50c105 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -18,4 +18,5 @@
   ],
   exports = ['@auto_value//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/bouncycastle/BUCK b/lib/bouncycastle/BUCK
index 68fa006..be8b2f7 100644
--- a/lib/bouncycastle/BUCK
+++ b/lib/bouncycastle/BUCK
@@ -2,19 +2,19 @@
 
 # This version must match the version that also appears in
 # gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
-VERSION = '1.52'
+VERSION = '1.55'
 
 maven_jar(
   name = 'bcprov',
   id = 'org.bouncycastle:bcprov-jdk15on:' + VERSION,
-  sha1 = '88a941faf9819d371e3174b5ed56a3f3f7d73269',
+  sha1 = '935f2e57a00ec2c489cbd2ad830d4a399708f979',
   license = 'DO_NOT_DISTRIBUTE', #'bouncycastle'
 )
 
 maven_jar(
   name = 'bcpg',
   id = 'org.bouncycastle:bcpg-jdk15on:' + VERSION,
-  sha1 = 'ff4665a4b5633ff6894209d5dd10b7e612291858',
+  sha1 = '54ce841795ecdf10f24e50c48d4fdec59c691699',
   license = 'DO_NOT_DISTRIBUTE', #'bouncycastle'
   deps = [':bcprov'],
 )
@@ -22,7 +22,7 @@
 maven_jar(
   name = 'bcpkix',
   id = 'org.bouncycastle:bcpkix-jdk15on:' + VERSION,
-  sha1 = 'b8ffac2bbc6626f86909589c8cc63637cc936504',
+  sha1 = '6392d8cba22b722c6570d660ca0b3921ff1bae4f',
   license = 'DO_NOT_DISTRIBUTE', #'bouncycastle'
   deps = [':bcprov'],
 )
diff --git a/lib/bouncycastle/BUILD b/lib/bouncycastle/BUILD
index 49c54ba..333c355 100644
--- a/lib/bouncycastle/BUILD
+++ b/lib/bouncycastle/BUILD
@@ -3,12 +3,14 @@
   neverlink = 1,
   exports = ['@bcprov//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
   name = 'bcprov-without-neverlink',
   exports = ['@bcprov//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
@@ -16,12 +18,14 @@
   neverlink = 1,
   exports = ['@bcpg//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
   name = 'bcpg-without-neverlink',
   exports = ['@bcpg//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
@@ -29,10 +33,12 @@
   neverlink = 1,
   exports = ['@bcpkix//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
   name = 'bcpkix-without-neverlink',
   exports = ['@bcpkix//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
diff --git a/lib/codemirror/BUCK b/lib/codemirror/BUCK
index a0e0e9a..be50417 100644
--- a/lib/codemirror/BUCK
+++ b/lib/codemirror/BUCK
@@ -1,14 +1,14 @@
 include_defs('//lib/maven.defs')
 include_defs('//lib/codemirror/cm.defs')
 
-VERSION = '5.17.0'
+VERSION = '5.19.0'
 TOP = 'META-INF/resources/webjars/codemirror/%s' % VERSION
 TOP_MINIFIED = 'META-INF/resources/webjars/codemirror-minified/%s' % VERSION
 
 maven_jar(
   name = 'codemirror-minified',
   id = 'org.webjars.npm:codemirror-minified:' + VERSION,
-  sha1 = '05ad901fc9be67eb7ba8997d896488093deb898e',
+  sha1 = '263bf4acb7c4429be3fe46908af240f9f629d51c',
   attach_source = False,
   license = 'codemirror-minified',
   visibility = [],
@@ -17,7 +17,7 @@
 maven_jar(
   name = 'codemirror-original',
   id = 'org.webjars.npm:codemirror:' + VERSION,
-  sha1 = 'c025b8d9aca1061e26d1fa482bea0ecea1412e85',
+  sha1 = 'e9ab382c6be240d55f112051bba3f6c637b798ce',
   attach_source = False,
   license = 'codemirror-original',
   visibility = [],
diff --git a/lib/codemirror/BUILD b/lib/codemirror/BUILD
new file mode 100644
index 0000000..0a62d41
--- /dev/null
+++ b/lib/codemirror/BUILD
@@ -0,0 +1,2 @@
+load('//lib/codemirror:cm.bzl', 'pkg_cm')
+pkg_cm()
diff --git a/lib/codemirror/cm.bzl b/lib/codemirror/cm.bzl
new file mode 100644
index 0000000..6400725
--- /dev/null
+++ b/lib/codemirror/cm.bzl
@@ -0,0 +1,358 @@
+load('//tools/bzl:genrule2.bzl', 'genrule2')
+
+CM_CSS = [
+  'lib/codemirror.css',
+  'addon/dialog/dialog.css',
+  'addon/merge/merge.css',
+  'addon/scroll/simplescrollbars.css',
+  'addon/search/matchesonscrollbar.css',
+  'addon/lint/lint.css',
+]
+
+CM_JS = [
+  'lib/codemirror.js',
+  'mode/meta.js',
+  'keymap/emacs.js',
+  'keymap/sublime.js',
+  'keymap/vim.js',
+]
+
+CM_ADDONS = [
+  'dialog/dialog.js',
+  'edit/closebrackets.js',
+  'edit/matchbrackets.js',
+  'edit/trailingspace.js',
+  'scroll/annotatescrollbar.js',
+  'scroll/simplescrollbars.js',
+  'search/jump-to-line.js',
+  'search/matchesonscrollbar.js',
+  'search/searchcursor.js',
+  'search/search.js',
+  'selection/mark-selection.js',
+  'mode/multiplex.js',
+  'mode/overlay.js',
+  'mode/simple.js',
+  'lint/lint.js',
+]
+
+# Available themes must be enumerated here,
+# in gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Theme.java,
+# in gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
+CM_THEMES = [
+  '3024-day',
+  '3024-night',
+  'abcdef',
+  'ambiance',
+  'base16-dark',
+  'base16-light',
+  'bespin',
+  'blackboard',
+  'cobalt',
+  'colorforth',
+  'dracula',
+  'eclipse',
+  'elegant',
+  'erlang-dark',
+  'hopscotch',
+  'icecoder',
+  'isotope',
+  'lesser-dark',
+  'liquibyte',
+  'material',
+  'mbo',
+  'mdn-like',
+  'midnight',
+  'monokai',
+  'neat',
+  'neo',
+  'night',
+  'paraiso-dark',
+  'paraiso-light',
+  'pastel-on-dark',
+  'railscasts',
+  'rubyblue',
+  'seti',
+  'solarized',
+  'the-matrix',
+  'tomorrow-night-bright',
+  'tomorrow-night-eighties',
+  'ttcn',
+  'twilight',
+  'vibrant-ink',
+  'xq-dark',
+  'xq-light',
+  'yeti',
+  'zenburn',
+]
+
+# Available modes must be enumerated here,
+# in gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java,
+# gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java,
+# and in CodeMirror's own mode/meta.js script.
+CM_MODES = [
+  'apl',
+  'asciiarmor',
+  'asn.1',
+  'asterisk',
+  'brainfuck',
+  'clike',
+  'clojure',
+  'cmake',
+  'cobol',
+  'coffeescript',
+  'commonlisp',
+  'crystal',
+  'css',
+  'cypher',
+  'd',
+  'dart',
+  'diff',
+  'django',
+  'dockerfile',
+  'dtd',
+  'dylan',
+  'ebnf',
+  'ecl',
+  'eiffel',
+  'elm',
+  'erlang',
+  'factor',
+  'fcl',
+  'forth',
+  'fortran',
+  'gas',
+  'gfm',
+  'gherkin',
+  'go',
+  'groovy',
+  'haml',
+  'handlebars',
+  'haskell-literate',
+  'haskell',
+  'haxe',
+  'htmlembedded',
+  'htmlmixed',
+  'http',
+  'idl',
+  'javascript',
+  'jinja2',
+  'jsx',
+  'julia',
+  'livescript',
+  'lua',
+  'markdown',
+  'mathematica',
+  'mbox',
+  'mirc',
+  'mllike',
+  'modelica',
+  'mscgen',
+  'mumps',
+  'nginx',
+  'nsis',
+  'ntriples',
+  'octave',
+  'oz',
+  'pascal',
+  'pegjs',
+  'perl',
+  'php',
+  'pig',
+  'powershell',
+  'properties',
+  'protobuf',
+  'pug',
+  'puppet',
+  'python',
+  'q',
+  'r',
+  'rpm',
+  'rst',
+  'ruby',
+  'rust',
+  'sas',
+  'sass',
+  'scheme',
+  'shell',
+  'sieve',
+  'slim',
+  'smalltalk',
+  'smarty',
+  'solr',
+  'soy',
+  'sparql',
+  'spreadsheet',
+  'sql',
+  'stex',
+  'stylus',
+  'swift',
+  'tcl',
+  'textile',
+  'tiddlywiki',
+  'tiki',
+  'toml',
+  'tornado',
+  'troff',
+  'ttcn-cfg',
+  'ttcn',
+  'turtle',
+  'twig',
+  'vb',
+  'vbscript',
+  'velocity',
+  'verilog',
+  'vhdl',
+  'vue',
+  'webidl',
+  'xml',
+  'xquery',
+  'yacas',
+  'yaml-frontmatter',
+  'yaml',
+  'z80',
+]
+
+VERSION = '5.19.0'
+TOP = 'META-INF/resources/webjars/codemirror/%s' % VERSION
+TOP_MINIFIED = 'META-INF/resources/webjars/codemirror-minified/%s' % VERSION
+LICENSE = '//lib:LICENSE-codemirror-original'
+LICENSE_MINIFIED = '//lib:LICENSE-codemirror-minified'
+
+DIFF_MATCH_PATCH_VERSION = '20121119-1'
+DIFF_MATCH_PATCH_TOP = ('META-INF/resources/webjars/google-diff-match-patch/%s'
+    % DIFF_MATCH_PATCH_VERSION)
+
+def pkg_cm():
+  for archive, suffix, top, license in [
+      ('@codemirror_original//jar', '', TOP, LICENSE),
+      ('@codemirror_minified//jar', '_r', TOP_MINIFIED, LICENSE_MINIFIED)
+  ]:
+    # Main JavaScript and addons
+    genrule2(
+      name = 'cm' + suffix,
+      cmd = ' && '.join([
+          "echo '/** @license' >$@",
+          'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
+          "echo '*/' >>$@",
+        ] +
+        ['unzip -p $(location %s) %s/%s >>$@' % (archive, top, n) for n in CM_JS] +
+        ['unzip -p $(location %s) %s/addon/%s >>$@' % (archive, top, n)
+         for n in CM_ADDONS]
+      ),
+      tools = [
+        '@codemirror_original//jar',
+        '@codemirror_minified//jar',
+      ],
+      outs = ['cm%s.js' % suffix],
+    )
+
+    # Main CSS
+    genrule2(
+      name = 'css' + suffix,
+      cmd = ' && '.join([
+          "echo '/** @license' >$@",
+          'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
+          "echo '*/' >>$@",
+        ] +
+        ['unzip -p $(location %s) %s/%s >>$@' % (archive, top, n)
+         for n in CM_CSS]
+      ),
+      tools = [
+        '@codemirror_original//jar',
+        '@codemirror_minified//jar',
+      ],
+      outs = ['cm%s.css' % suffix],
+    )
+
+    # Modes
+    for n in CM_MODES:
+      genrule2(
+        name = 'mode_%s%s' % (n, suffix),
+        cmd = ' && '.join([
+            "echo '/** @license' >$@",
+            'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
+            "echo '*/' >>$@",
+            'unzip -p $(location %s) %s/mode/%s/%s.js >>$@' % (archive, top, n, n),
+          ]
+        ),
+        tools = [
+          '@codemirror_original//jar',
+          '@codemirror_minified//jar',
+        ],
+        outs = ['mode_%s%s.js' % (n, suffix)],
+      )
+
+    # Themes
+    for n in CM_THEMES:
+      genrule2(
+        name = 'theme_%s%s' % (n, suffix),
+        cmd = ' && '.join([
+            "echo '/** @license' >$@",
+            'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
+            "echo '*/' >>$@",
+            'unzip -p $(location %s) %s/theme/%s.css >>$@' % (archive, top, n)
+          ]
+        ),
+        tools = [
+          '@codemirror_original//jar',
+          '@codemirror_minified//jar',
+        ],
+        outs = ['theme_%s%s.css' % (n, suffix)],
+      )
+
+    # Merge Addon bundled with diff-match-patch
+    genrule2(
+      name = 'addon_merge%s' % suffix,
+      cmd = ' && '.join([
+          "echo '/** @license' >$@",
+          'unzip -p $(location %s) %s/LICENSE >>$@' % (archive, top),
+          "echo '*/\n' >>$@",
+          "echo '// The google-diff-match-patch library is from https://google-diff-match-patch.googlecode.com/svn-history/r106/trunk/javascript/diff_match_patch.js\n' >> $@",
+          "echo '/** @license' >>$@",
+          "echo 'LICENSE-Apache2.0' >>$@",
+          "echo '*/' >>$@",
+          'unzip -p $(location @diff_match_patch//jar) %s/diff_match_patch.js >>$@' % DIFF_MATCH_PATCH_TOP,
+          "echo ';' >> $@",
+          'unzip -p $(location %s) %s/addon/merge/merge.js >>$@' % (archive, top)
+        ]
+      ),
+      tools = [
+        '@diff_match_patch//jar',
+        '@codemirror_original//jar',
+        '@codemirror_minified//jar',
+      ],
+      outs = ['addon_merge%s.js' % suffix],
+    )
+
+    # Jar packaging
+    genrule2(
+      name = 'jar' + suffix,
+      cmd = ' && '.join([
+        'cd $$TMP',
+        'mkdir -p net/codemirror/{addon,lib,mode,theme}',
+        'cp $$ROOT/$(location :css%s) net/codemirror/lib/cm.css' % suffix,
+        'cp $$ROOT/$(location :cm%s) net/codemirror/lib/cm.js' % suffix]
+        + ['cp $$ROOT/$(location :mode_%s%s) net/codemirror/mode/%s.js' % (n, suffix, n)
+           for n in CM_MODES]
+        + ['cp $$ROOT/$(location :theme_%s%s) net/codemirror/theme/%s.css' % (n, suffix, n)
+           for n in CM_THEMES]
+        + ['cp $$ROOT/$(location :addon_merge%s) net/codemirror/addon/merge_bundled.js' % suffix]
+        + ['zip -qr $$ROOT/$@ net/codemirror/{addon,lib,mode,theme}']),
+      tools = [
+        ':addon_merge%s' % suffix,
+        ':cm%s' % suffix,
+        ':css%s' % suffix,
+      ] + [
+        ':mode_%s%s' % (n, suffix) for n in CM_MODES
+      ] + [
+        ':theme_%s%s' % (n, suffix) for n in CM_THEMES
+      ],
+      outs = ['codemirror%s.jar' % suffix],
+    )
+
+    native.java_import(
+      name = 'codemirror' + suffix,
+      jars = [':jar%s' % suffix],
+      visibility = ['//visibility:public'],
+      data = [license],
+    )
diff --git a/lib/codemirror/cm.defs b/lib/codemirror/cm.defs
index baf2ce5..a1be90f 100644
--- a/lib/codemirror/cm.defs
+++ b/lib/codemirror/cm.defs
@@ -132,7 +132,6 @@
   'htmlmixed',
   'http',
   'idl',
-  'jade',
   'javascript',
   'jinja2',
   'jsx',
@@ -160,6 +159,7 @@
   'powershell',
   'properties',
   'protobuf',
+  'pug',
   'puppet',
   'python',
   'q',
diff --git a/lib/commons/BUCK b/lib/commons/BUCK
index 7c27477..5c2e9b2 100644
--- a/lib/commons/BUCK
+++ b/lib/commons/BUCK
@@ -19,8 +19,8 @@
 
 maven_jar(
   name = 'compress',
-  id = 'org.apache.commons:commons-compress:1.7',
-  sha1 = 'ab365c96ee9bc88adcc6fa40d185c8e15a31410d',
+  id = 'org.apache.commons:commons-compress:1.12',
+  sha1 = '84caa68576e345eb5e7ae61a0e5a9229eb100d7b',
   license = 'Apache2.0',
   exclude = ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt'],
 )
@@ -47,6 +47,13 @@
 )
 
 maven_jar(
+  name = 'lang3',
+  id = 'org.apache.commons:commons-lang3:3.3.2',
+  sha1 = '90a3822c38ec8c996e84c16a3477ef632cbc87a3',
+  license = 'Apache2.0',
+)
+
+maven_jar(
   name = 'net',
   id = 'commons-net:commons-net:3.5',
   sha1 = '342fc284019f590e1308056990fdb24a08f06318',
diff --git a/lib/commons/BUILD b/lib/commons/BUILD
index 8c42e53f..d4d6145 100644
--- a/lib/commons/BUILD
+++ b/lib/commons/BUILD
@@ -1,31 +1,44 @@
+package(default_visibility = ['//visibility:public'])
+
 java_library(
   name = 'codec',
   exports = ['@commons_codec//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'collections',
   exports = ['@commons_collections//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'compress',
   exports = ['@commons_compress//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'lang',
   exports = ['@commons_lang//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'lang3',
+  exports = [ '@commons_lang3//jar'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'net',
   exports = ['@commons_net//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -33,22 +46,26 @@
   exports = ['@commons_dbcp//jar'],
   runtime_deps = [':pool'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'pool',
   exports = ['@commons_pool//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'oro',
   exports = ['@commons_oro//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache1.1'],
 )
 
 java_library(
   name = 'validator',
   exports = ['@commons_validator//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/dropwizard/BUILD b/lib/dropwizard/BUILD
index 9d4a8d3..b456d5e 100644
--- a/lib/dropwizard/BUILD
+++ b/lib/dropwizard/BUILD
@@ -2,4 +2,5 @@
   name = 'dropwizard-core',
   exports = ['@dropwizard_core//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/easymock/BUCK b/lib/easymock/BUCK
index 93640a0..cabd62e 100644
--- a/lib/easymock/BUCK
+++ b/lib/easymock/BUCK
@@ -2,28 +2,28 @@
 
 maven_jar(
   name = 'easymock',
-  id = 'org.easymock:easymock:3.4', # When bumping the version
+  id = 'org.easymock:easymock:3.1', # When bumping the version
   # number, make sure to also move powermock to a compatible version
-  sha1 = '9fdeea183a399f25c2469497612cad131e920fa3',
+  sha1 = '3e127311a86fc2e8f550ef8ee4abe094bbcf7e7e',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
-    ':cglib-2_2',
+    ':cglib-3_2',
     ':objenesis',
   ],
 )
 
 maven_jar(
-  name = 'cglib-2_2',
-  id = 'cglib:cglib-nodep:2.2.2',
-  sha1 = '00d456bb230c70c0b95c76fb28e429d42f275941',
+  name = 'cglib-3_2',
+  id = 'cglib:cglib-nodep:3.2.0',
+  sha1 = 'cf1ca207c15b04ace918270b6cb3f5601160cdfd',
   license = 'DO_NOT_DISTRIBUTE',
   attach_source = False,
 )
 
 maven_jar(
   name = 'objenesis',
-  id = 'org.objenesis:objenesis:2.2',
-  sha1 = '3fb533efdaa50a768c394aa4624144cf8df17845',
+  id = 'org.objenesis:objenesis:1.3',
+  sha1 = 'dc13ae4faca6df981fc7aeb5a522d9db446d5d50',
   license = 'DO_NOT_DISTRIBUTE',
   visibility = ['//lib/powermock:powermock-reflect'],
   attach_source = False,
diff --git a/lib/easymock/BUILD b/lib/easymock/BUILD
index df77128..fce3ff7 100644
--- a/lib/easymock/BUILD
+++ b/lib/easymock/BUILD
@@ -2,21 +2,24 @@
   name = 'easymock',
   exports = ['@easymock//jar'],
   runtime_deps = [
-    ':cglib-2_2',
+    ':cglib-3_2',
     ':objenesis',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
-  name = 'cglib-2_2',
-  exports = ['@cglib_2_2//jar'],
+  name = 'cglib-3_2',
+  exports = ['@cglib_3_2//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
   name = 'objenesis',
   exports = ['@objenesis//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
diff --git a/lib/elasticsearch/BUCK b/lib/elasticsearch/BUCK
new file mode 100644
index 0000000..373f4d2
--- /dev/null
+++ b/lib/elasticsearch/BUCK
@@ -0,0 +1,104 @@
+include_defs('//lib/maven.defs')
+
+# Java client library for Elasticsearch.
+maven_jar(
+  name = 'elasticsearch',
+  id = 'org.elasticsearch:elasticsearch:2.4.0',
+  sha1 = 'aeb9704a76fa8654c348f38fcbb993a952a7ab07',
+  attach_source = True,
+  repository = MAVEN_CENTRAL,
+  license = 'Apache2.0',
+  deps = [
+    ':jna',
+    ':hppc',
+    ':jsr166e',
+    ':netty',
+    ':t-digest',
+    ':compress-lzf',
+    '//lib/joda:joda-time',
+    '//lib/lucene:lucene-codecs',
+    '//lib/lucene:lucene-highlighter',
+    '//lib/lucene:lucene-join',
+    '//lib/lucene:lucene-memory',
+    '//lib/lucene:lucene-sandbox',
+    '//lib/lucene:lucene-suggest',
+    '//lib/lucene:lucene-queries',
+    '//lib/lucene:lucene-spatial',
+    '//lib/jackson:jackson-core',
+    '//lib/jackson:jackson-dataformat-cbor',
+    '//lib/jackson:jackson-dataformat-smile',
+  ]
+)
+
+# Java REST client for Elasticsearch.
+VERSION = '2.0.3'
+
+maven_jar(
+  name = 'jest-common',
+  id = 'io.searchbox:jest-common:' + VERSION,
+  sha1 = 'f304c66894aaf2f6c17a886bc826f09c7a161cf9',
+  license = 'Apache2.0',
+)
+
+maven_jar(
+  name = 'jest',
+  id = 'io.searchbox:jest:' + VERSION,
+  sha1 = 'b8f9ed1423489b361804e47f640515ea9f1fa08d',
+  license = 'Apache2.0',
+  deps = [
+    ':elasticsearch',
+    ':jest-common',
+    '//lib/commons:lang3',
+    '//lib/httpcomponents:httpasyncclient',
+    '//lib/httpcomponents:httpclient',
+    '//lib/httpcomponents:httpcore-nio',
+    '//lib/httpcomponents:httpcore-niossl',
+  ],
+)
+
+maven_jar(
+  name = 'compress-lzf',
+  id = 'com.ning:compress-lzf:1.0.2',
+  sha1 = '62896e6fca184c79cc01a14d143f3ae2b4f4b4ae',
+  license = 'Apache2.0',
+  visibility = ['//lib/elasticsearch:elasticsearch'],
+)
+
+maven_jar(
+  name = 'hppc',
+  id = 'com.carrotsearch:hppc:0.7.1',
+  sha1 = '8b5057f74ea378c0150a1860874a3ebdcb713767',
+  license = 'Apache2.0',
+  visibility = ['//lib/elasticsearch:elasticsearch'],
+)
+
+maven_jar(
+  name = 'jsr166e',
+  id = 'com.twitter:jsr166e:1.1.0',
+  sha1 = '233098147123ee5ddcd39ffc57ff648be4b7e5b2',
+  license = 'Apache2.0',
+  visibility = ['//lib/elasticsearch:elasticsearch'],
+)
+
+maven_jar(
+  name = 'netty',
+  id = 'io.netty:netty:3.10.0.Final',
+  sha1 = 'ad61cd1bba067e6634ddd3e160edf0727391ac30',
+  license = 'Apache2.0',
+  visibility = ['//lib/elasticsearch:elasticsearch'],
+)
+
+maven_jar(
+  name = 't-digest',
+  id = 'com.tdunning:t-digest:3.0',
+  sha1 = '84ccf145ac2215e6bfa63baa3101c0af41017cfc',
+  license = 'Apache2.0',
+  visibility = ['//lib/elasticsearch:elasticsearch'],
+)
+
+maven_jar(
+  name = 'jna',
+  id = 'net.java.dev.jna:jna:4.1.0',
+  sha1 = '1c12d070e602efd8021891cdd7fd18bc129372d4',
+  license = 'Apache2.0',
+)
diff --git a/lib/elasticsearch/BUILD b/lib/elasticsearch/BUILD
new file mode 100644
index 0000000..6c3d423
--- /dev/null
+++ b/lib/elasticsearch/BUILD
@@ -0,0 +1,92 @@
+package(default_visibility=['//visibility:public'])
+
+java_library(
+  name = 'elasticsearch',
+  data = [ '//lib:LICENSE-Apache2.0' ],
+  exports = [ '@elasticsearch//jar' ],
+  runtime_deps = [
+    ':jna',
+    ':hppc',
+    ':jsr166e',
+    ':netty',
+    ':t-digest',
+    ':compress-lzf',
+    '//lib/joda:joda-time',
+    '//lib/lucene:lucene-codecs',
+    '//lib/lucene:lucene-highlighter',
+    '//lib/lucene:lucene-join',
+    '//lib/lucene:lucene-memory',
+    '//lib/lucene:lucene-sandbox',
+    '//lib/lucene:lucene-suggest',
+    '//lib/lucene:lucene-queries',
+    '//lib/lucene:lucene-spatial',
+    '//lib/jackson:jackson-core',
+    '//lib/jackson:jackson-dataformat-cbor',
+    '//lib/jackson:jackson-dataformat-smile',
+  ]
+)
+
+# Java REST client for Elasticsearch.
+VERSION = '0.1.7'
+
+java_library(
+  name = 'jest-common',
+  exports = [ '@jest_common//jar' ],
+  data = [ '//lib:LICENSE-Apache2.0' ],
+)
+
+java_library(
+  name = 'jest',
+  exports = [ '@jest//jar' ],
+  data = [ '//lib:LICENSE-Apache2.0' ],
+  runtime_deps = [
+    ':elasticsearch',
+    ':jest-common',
+    '//lib/commons:lang3',
+    '//lib/httpcomponents:httpasyncclient',
+    '//lib/httpcomponents:httpclient',
+    '//lib/httpcomponents:httpcore-nio',
+    '//lib/httpcomponents:httpcore-niossl',
+  ],
+)
+
+java_library(
+  name = 'compress-lzf',
+  exports = [ '@compress_lzf//jar' ],
+  data = [ '//lib:LICENSE-Apache2.0' ],
+  visibility = ['//lib/elasticsearch:__pkg__'],
+)
+
+java_library(
+  name = 'hppc',
+  exports = [ '@hppc//jar' ],
+  data = [ '//lib:LICENSE-Apache2.0' ],
+  visibility = ['//lib/elasticsearch:__pkg__'],
+)
+
+java_library(
+  name = 'jsr166e',
+  exports = [ '@jsr166e//jar' ],
+  data = [ '//lib:LICENSE-Apache2.0' ],
+  visibility = ['//lib/elasticsearch:__pkg__'],
+)
+
+java_library(
+  name = 'netty',
+  exports = [ '@netty//jar' ],
+  data = [ '//lib:LICENSE-Apache2.0' ],
+  visibility = ['//lib/elasticsearch:__pkg__'],
+)
+
+java_library(
+  name = 't-digest',
+  exports = [ '@t_digest//jar' ],
+  data = [ '//lib:LICENSE-Apache2.0' ],
+  visibility = ['//lib/elasticsearch:__pkg__'],
+)
+
+java_library(
+  name = 'jna',
+  exports = [ '@jna//jar' ],
+  data = [ '//lib:LICENSE-Apache2.0' ],
+)
diff --git a/lib/fonts/BUILD b/lib/fonts/BUILD
new file mode 100644
index 0000000..88db107
--- /dev/null
+++ b/lib/fonts/BUILD
@@ -0,0 +1,31 @@
+load('//tools/bzl:genrule2.bzl', 'genrule2')
+
+# Source Code Pro. Version 2.010 Roman / 1.030 Italics
+# https://github.com/adobe-fonts/source-code-pro/releases/tag/2.010R-ro%2F1.030R-it
+filegroup(
+  name = 'sourcecodepro',
+  srcs = [
+    'SourceCodePro-Regular.woff',
+    'SourceCodePro-Regular.woff2'
+  ],
+  data = [ "//lib:LICENSE-OFL1.1" ],
+  visibility = ['//visibility:public'],
+)
+
+# Open Sans at Revision 53a5266 and converted using a Google woff file
+# converter (same one that Google Fonts uses).
+# https://github.com/google/fonts/tree/master/apache/opensans
+genrule2(
+  name = 'opensans',
+  cmd = 'zip -rq $@ $(SRCS)',
+  srcs = [
+    'OpenSans-Bold.woff',
+    'OpenSans-Bold.woff2',
+    'OpenSans-Regular.woff',
+    'OpenSans-Regular.woff2'
+  ],
+  outs = [ 'opensans.zip' ],
+# TODO(hanwen): license.
+#  license = 'Apache2.0',
+  visibility = ['//visibility:public'],
+)
diff --git a/lib/guava.bzl b/lib/guava.bzl
new file mode 100644
index 0000000..a7f65c1
--- /dev/null
+++ b/lib/guava.bzl
@@ -0,0 +1,3 @@
+GUAVA_VERSION = '20.0-rc1'
+GUAVA_BIN_SHA1 = '4c2a4581b69b16a57968da32fcadb8e362b639b2'
+GUAVA_DOC_URL = 'https://google.github.io/guava/releases/' + GUAVA_VERSION + '/api/docs/'
diff --git a/lib/guice/BUCK b/lib/guice/BUCK
index 867b521..8022ac8 100644
--- a/lib/guice/BUCK
+++ b/lib/guice/BUCK
@@ -12,6 +12,7 @@
   exported_deps = [
     ':guice_library',
     ':javax-inject',
+    ':multibindings',
   ],
   visibility = ['PUBLIC'],
 )
@@ -63,3 +64,16 @@
   license = 'Apache2.0',
   visibility = ['PUBLIC'],
 )
+
+maven_jar(
+  name = 'multibindings',
+  id = 'com.google.inject.extensions:guice-multibindings:' + VERSION,
+  sha1 = '3b27257997ac51b0f8d19676f1ea170427e86d51',
+  exclude_java_sources = True,
+  exclude = EXCLUDE + [
+    'META-INF/maven/com.google.guava/guava/pom.properties',
+    'META-INF/maven/com.google.guava/guava/pom.xml',
+  ],
+  license = 'Apache2.0',
+  visibility = ['PUBLIC']
+)
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
index acade50..43018e0 100644
--- a/lib/guice/BUILD
+++ b/lib/guice/BUILD
@@ -3,8 +3,10 @@
   exports = [
     ':guice_library',
     ':javax-inject',
+    ':multibindings',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -12,6 +14,7 @@
   exports = ['@guice_library//jar'],
   runtime_deps = ['aopalliance'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -19,6 +22,7 @@
   exports = ['@guice_assistedinject//jar'],
   runtime_deps = [':guice'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -26,14 +30,25 @@
   exports = ['@guice_servlet//jar'],
   runtime_deps = [':guice'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'aopalliance',
   exports = ['@aopalliance//jar'],
+  data = ['//lib:LICENSE-PublicDomain'],
 )
 
 java_library(
   name = 'javax-inject',
   exports = ['@javax_inject//jar'],
+  visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'multibindings',
+  exports = [ '@multibindings//jar' ],
+  visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/gwt/BUCK b/lib/gwt/BUCK
index 44a9341..6e8b8d1 100644
--- a/lib/gwt/BUCK
+++ b/lib/gwt/BUCK
@@ -1,11 +1,11 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '2.7.0'
+VERSION = '2.8.0'
 
 maven_jar(
   name = 'user',
   id = 'com.google.gwt:gwt-user:' + VERSION,
-  sha1 = 'bdc7af42581745d3d79c2efe0b514f432b998a5b',
+  sha1 = '518579870499e15531f454f35dca0772d7fa31f7',
   license = 'Apache2.0',
   attach_source = False,
 )
@@ -13,10 +13,9 @@
 maven_jar(
   name = 'dev',
   id = 'com.google.gwt:gwt-dev:' + VERSION,
-  sha1 = 'c2c3dd5baf648a0bb199047a818be5e560f48982',
+  sha1 = 'f160a61272c5ebe805cd2d3d3256ed3ecf14893f',
   license = 'Apache2.0',
   attach_source = False,
-  exclude = ['org/eclipse/jetty/*'],
 )
 
 maven_jar(
@@ -28,3 +27,47 @@
   visibility = ['PUBLIC'],
 )
 
+maven_jar(
+  name = 'jsinterop-annotations',
+  id = 'com.google.jsinterop:jsinterop-annotations:1.0.0',
+  bin_sha1 = '23c3a3c060ffe4817e67673cc8294e154b0a4a95',
+  src_sha1 = '5d7c478efbfccc191430d7c118d7bd2635e43750',
+  license = 'Apache2.0',
+  visibility = ['PUBLIC'],
+)
+
+maven_jar(
+  name = 'ant',
+  id = 'ant:ant:1.6.5',
+  bin_sha1 = '7d18faf23df1a5c3a43613952e0e8a182664564b',
+  src_sha1 = '9e0a847494563f35f9b02846a1c1eb4aa2ee5a9a',
+  license = 'Apache2.0',
+  visibility = ['PUBLIC'],
+)
+
+maven_jar(
+  name = 'colt',
+  id = 'colt:colt:1.2.0',
+  attach_source = False,
+  bin_sha1 = '0abc984f3adc760684d49e0f11ddf167ba516d4f',
+  license = 'DO_NOT_DISTRIBUTE',
+  visibility = ['PUBLIC'],
+)
+
+maven_jar(
+  name = 'tapestry',
+  id = 'tapestry:tapestry:4.0.2',
+  attach_source = False,
+  bin_sha1 = 'e855a807425d522e958cbce8697f21e9d679b1f7',
+  license = 'Apache2.0',
+  visibility = ['PUBLIC'],
+)
+
+maven_jar(
+  name = 'w3c-css-sac',
+  id = 'org.w3c.css:sac:1.3',
+  attach_source = False,
+  bin_sha1 = 'cdb2dcb4e22b83d6b32b93095f644c3462739e82',
+  license = 'DO_NOT_DISTRIBUTE',
+  visibility = ['PUBLIC'],
+)
diff --git a/lib/gwt/BUILD b/lib/gwt/BUILD
index 2168bb4..46d8f6d 100644
--- a/lib/gwt/BUILD
+++ b/lib/gwt/BUILD
@@ -2,8 +2,29 @@
   name = n,
   exports = ['@%s//jar' % n.replace("-", "_")],
   visibility = ["//visibility:public"],
+  data = ['//lib:LICENSE-Apache2.0'],
 ) for n in [
-  'javax-validation',
+  'ant',
+  'colt',
   'dev',
+  'javax-validation',
+  'jsinterop-annotations',
+  'tapestry',
   'user',
+  'w3c-css-sac',
 ]]
+
+java_library(
+  name = 'javax-validation_src',
+  exports = ['@javax_validation_src//jar'],
+  visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'jsinterop-annotations_src',
+  exports = ['@jsinterop_annotations_src//jar'],
+  visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
diff --git a/lib/highlightjs/BUILD b/lib/highlightjs/BUILD
new file mode 100644
index 0000000..5fb2a71
--- /dev/null
+++ b/lib/highlightjs/BUILD
@@ -0,0 +1,4 @@
+
+exports_files([
+  'highlight.min.js',
+])
diff --git a/lib/httpcomponents/BUCK b/lib/httpcomponents/BUCK
index 03669f2..1e56f94 100644
--- a/lib/httpcomponents/BUCK
+++ b/lib/httpcomponents/BUCK
@@ -39,3 +39,25 @@
   src_sha1 = '5394d3715181a87009032335a55b0a9789f6e26f',
   license = 'Apache2.0',
 )
+
+maven_jar(
+  name = 'httpasyncclient',
+  id = 'org.apache.httpcomponents:httpasyncclient:4.1.2',
+  sha1 = '95aa3e6fb520191a0970a73cf09f62948ee614be',
+  license = 'Apache2.0',
+)
+
+maven_jar(
+  name = 'httpcore-nio',
+  id = 'org.apache.httpcomponents:httpcore-nio:' + VERSION,
+  sha1 = 'a8c5e3c3bfea5ce23fb647c335897e415eb442e3',
+  license = 'Apache2.0',
+)
+
+maven_jar(
+  name = 'httpcore-niossl',
+  id = 'org.apache.httpcomponents:httpcore-niossl:4.0-alpha6',
+  sha1 = '9c662e7247ca8ceb1de5de629f685c9ef3e4ab58',
+  license = 'Apache2.0',
+  attach_source = False,
+)
diff --git a/lib/httpcomponents/BUILD b/lib/httpcomponents/BUILD
index 74ab00a..c11df29 100644
--- a/lib/httpcomponents/BUILD
+++ b/lib/httpcomponents/BUILD
@@ -1,8 +1,11 @@
+package(default_visibility = ['//visibility:public'])
+
 java_library(
   name = 'fluent-hc',
   exports = ['@fluent_hc//jar'],
   runtime_deps = [':httpclient'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -14,16 +17,37 @@
     '//lib/log:jcl-over-slf4j',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'httpcore',
   exports = ['@httpcore//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'httpmime',
   exports = ['@httpmime//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'httpasyncclient',
+  exports = [ '@httpasyncclient//jar' ],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'httpcore-nio',
+  exports =  [ '@httpcore_nio//jar' ],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'httpcore-niossl',
+  exports = ['@httpcore_niossl//jar'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/jackson/BUCK b/lib/jackson/BUCK
new file mode 100644
index 0000000..46056b5
--- /dev/null
+++ b/lib/jackson/BUCK
@@ -0,0 +1,26 @@
+include_defs('//lib/maven.defs')
+
+VERSION = '2.6.6'
+
+maven_jar(
+  name = 'jackson-core',
+  id = 'com.fasterxml.jackson.core:jackson-core:' + VERSION,
+  sha1 = '02eb801df67aacaf5b1deb4ac626e1964508e47b',
+  license = 'Apache2.0',
+)
+
+maven_jar(
+  name = 'jackson-dataformat-smile',
+  id = 'com.fasterxml.jackson.dataformat:jackson-dataformat-smile:' + VERSION,
+  sha1 = 'ccbfc948748ed2754a58c1af9e0a02b5cc1aed69',
+  license = 'Apache2.0',
+)
+
+maven_jar(
+  name = 'jackson-dataformat-cbor',
+  id = 'com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:' + VERSION,
+  sha1 = '34c7b7ff495fc6b049612bdc9db0900a68e112f8',
+  license = 'Apache2.0'
+)
+
+
diff --git a/lib/jackson/BUILD b/lib/jackson/BUILD
new file mode 100644
index 0000000..87ea42e4
--- /dev/null
+++ b/lib/jackson/BUILD
@@ -0,0 +1,21 @@
+package(default_visibility = [ "//visibility:public"])
+
+VERSION = '2.6.6'
+
+java_library(
+  name = 'jackson-core',
+  exports = [ '@jackson_core//jar' ],
+  data = [ '//lib:LICENSE-Apache2.0' ],
+)
+
+java_library(
+  name = 'jackson-dataformat-smile',
+  exports = [ '@jackson_dataformat_smile//jar' ],
+  data = [ '//lib:LICENSE-Apache2.0' ],
+)
+
+java_library(
+  name = 'jackson-dataformat-cbor',
+  exports = [ '@jackson_dataformat_cbor//jar' ],
+  data = [ '//lib:LICENSE-Apache2.0' ],
+)
diff --git a/lib/jetty/BUCK b/lib/jetty/BUCK
index cc22b80..e24cfe5 100644
--- a/lib/jetty/BUCK
+++ b/lib/jetty/BUCK
@@ -1,12 +1,12 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '9.2.14.v20151106'
+VERSION = '9.3.11.v20160721'
 EXCLUDE = ['about.html']
 
 maven_jar(
   name = 'servlet',
   id = 'org.eclipse.jetty:jetty-servlet:' + VERSION,
-  sha1 = '3a2cd4d8351a38c5d60e0eee010fee11d87483ef',
+  sha1 = 'd550147b85c73ea81084a4ac7915ba7f609021c5',
   license = 'Apache2.0',
   deps = [':security'],
   exclude = EXCLUDE,
@@ -15,7 +15,7 @@
 maven_jar(
   name = 'security',
   id = 'org.eclipse.jetty:jetty-security:' + VERSION,
-  sha1 = '2d36974323fcb31e54745c1527b996990835db67',
+  sha1 = '1cbefc5d1196b9e1ca6f4cc36738998a6ebde8bf',
   license = 'Apache2.0',
   deps = [':server'],
   exclude = EXCLUDE,
@@ -25,7 +25,7 @@
 maven_jar(
   name = 'servlets',
   id = 'org.eclipse.jetty:jetty-servlets:' + VERSION,
-  sha1 = 'a75c78a0ee544073457ca5ee9db20fdc6ed55225',
+  sha1 = 'a9f7a43977151a463aa21a9b0e882aa3d25452ef',
   license = 'Apache2.0',
   exclude = EXCLUDE,
   visibility = [
@@ -37,7 +37,7 @@
 maven_jar(
   name = 'server',
   id = 'org.eclipse.jetty:jetty-server:' + VERSION,
-  sha1 = '70b22c1353e884accf6300093362b25993dac0f5',
+  sha1 = 'd932e0dc1e9bd4839ae446754615163d60271a66',
   license = 'Apache2.0',
   exported_deps = [
     ':continuation',
@@ -49,7 +49,7 @@
 maven_jar(
   name = 'jmx',
   id = 'org.eclipse.jetty:jetty-jmx:' + VERSION,
-  sha1 = '617edc5e966b4149737811ef8b289cd94b831bab',
+  sha1 = '21a658d2f5eb87c23eef4911966625ea95f66d32',
   license = 'Apache2.0',
   exported_deps = [
     ':continuation',
@@ -61,7 +61,7 @@
 maven_jar(
   name = 'continuation',
   id = 'org.eclipse.jetty:jetty-continuation:' + VERSION,
-  sha1 = '8909d62fd7e28351e2da30de6fb4105539b949c0',
+  sha1 = '92a91c0dcc5f5d779a1c9f94038332be3f46c9df',
   license = 'Apache2.0',
   exclude = EXCLUDE,
 )
@@ -69,7 +69,7 @@
 maven_jar(
   name = 'http',
   id = 'org.eclipse.jetty:jetty-http:' + VERSION,
-  sha1 = '699ad1f2fa6fb0717e1b308a8c9e1b8c69d81ef6',
+  sha1 = 'dcfb95e5b886a981bb76467b911c5b706117f9cf',
   license = 'Apache2.0',
   exported_deps = [':io'],
   exclude = EXCLUDE,
@@ -78,7 +78,7 @@
 maven_jar(
   name = 'io',
   id = 'org.eclipse.jetty:jetty-io:' + VERSION,
-  sha1 = 'dfa4137371a3f08769820138ca1a2184dacda267',
+  sha1 = 'db5f4f481159894a4b670072a34917b5414d0c98',
   license = 'Apache2.0',
   exported_deps = [':util'],
   exclude = EXCLUDE,
@@ -88,7 +88,7 @@
 maven_jar(
   name = 'util',
   id = 'org.eclipse.jetty:jetty-util:' + VERSION,
-  sha1 = '0057e00b912ae0c35859ac81594a996007706a0b',
+  sha1 = '1812ffd5a04698051180d582c146ca807760c808',
   license = 'Apache2.0',
   exclude = EXCLUDE,
   visibility = [],
diff --git a/lib/jetty/BUILD b/lib/jetty/BUILD
index da3af1c..d6e6355 100644
--- a/lib/jetty/BUILD
+++ b/lib/jetty/BUILD
@@ -3,6 +3,7 @@
   exports = ['@jetty_servlet//jar'],
   runtime_deps = [':security'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -10,12 +11,14 @@
   exports = ['@jetty_security//jar'],
   runtime_deps = [':server'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'servlets',
   exports = ['@jetty_servlets//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -26,6 +29,7 @@
     ':http',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -36,12 +40,14 @@
     ':http',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'continuation',
   exports = ['@jetty_continuation//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -51,6 +57,7 @@
     ':io',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -59,9 +66,11 @@
     '@jetty_io//jar',
     ':util',
   ],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'util',
   exports = ['@jetty_util//jar'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/jgit/BUILD b/lib/jgit/BUILD
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/jgit/BUILD
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
new file mode 100644
index 0000000..996e44d
--- /dev/null
+++ b/lib/jgit/jgit.bzl
@@ -0,0 +1,3 @@
+JGIT_VERS = '4.5.0.201609210915-r'
+DOC_VERS = JGIT_VERS # Set to VERS unless using a snapshot
+JGIT_DOC_URL="http://download.eclipse.org/jgit/site/" + DOC_VERS + "/apidocs"
diff --git a/lib/jgit/org.eclipse.jgit.archive/BUCK b/lib/jgit/org.eclipse.jgit.archive/BUCK
index 7c967b3..02f99c6 100644
--- a/lib/jgit/org.eclipse.jgit.archive/BUCK
+++ b/lib/jgit/org.eclipse.jgit.archive/BUCK
@@ -3,7 +3,7 @@
 
 maven_jar(
   name = 'jgit-archive',
-  id = 'org.eclipse.jgit:org.eclipse.jgit.archive:' + VERS,
+  id = 'org.eclipse.jgit:org.eclipse.jgit.archive:' + JGIT_VERS,
   sha1 = '2db2e7666672a31fa41b7e1dadcba51df6d30954',
   license = 'jgit',
   repository = REPO,
diff --git a/lib/jgit/org.eclipse.jgit.archive/BUILD b/lib/jgit/org.eclipse.jgit.archive/BUILD
index 8fa94f2..7d6fe22 100644
--- a/lib/jgit/org.eclipse.jgit.archive/BUILD
+++ b/lib/jgit/org.eclipse.jgit.archive/BUILD
@@ -3,4 +3,5 @@
   exports = ['@jgit_archive//jar'],
   runtime_deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-jgit'],
 )
diff --git a/lib/jgit/org.eclipse.jgit.http.server/BUCK b/lib/jgit/org.eclipse.jgit.http.server/BUCK
index 06865cb..5dd3777 100644
--- a/lib/jgit/org.eclipse.jgit.http.server/BUCK
+++ b/lib/jgit/org.eclipse.jgit.http.server/BUCK
@@ -3,7 +3,7 @@
 
 maven_jar(
   name = 'jgit-servlet',
-  id = 'org.eclipse.jgit:org.eclipse.jgit.http.server:' + VERS,
+  id = 'org.eclipse.jgit:org.eclipse.jgit.http.server:' + JGIT_VERS,
   sha1 = '6e36638888918d9941dddec7e2abe1f162cc74d9',
   license = 'jgit',
   repository = REPO,
diff --git a/lib/jgit/org.eclipse.jgit.http.server/BUILD b/lib/jgit/org.eclipse.jgit.http.server/BUILD
index 6a442cc..a453513 100644
--- a/lib/jgit/org.eclipse.jgit.http.server/BUILD
+++ b/lib/jgit/org.eclipse.jgit.http.server/BUILD
@@ -1,6 +1,15 @@
+load('//tools/bzl:unsign.bzl', 'unsign_jars')
+
 java_library(
-  name = 'jgit-servlet',
+  name = 'jgit-servlet-signed',
   exports = ['@jgit_servlet//jar'],
   runtime_deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-jgit'],
+)
+
+unsign_jars(
+  name = 'jgit-servlet',
+  deps = [':jgit-servlet-signed'],
+  visibility = ['//visibility:public'],
 )
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUCK b/lib/jgit/org.eclipse.jgit.junit/BUCK
index 77b637a..e5cd5c0 100644
--- a/lib/jgit/org.eclipse.jgit.junit/BUCK
+++ b/lib/jgit/org.eclipse.jgit.junit/BUCK
@@ -3,7 +3,7 @@
 
 maven_jar(
   name = 'junit',
-  id = 'org.eclipse.jgit:org.eclipse.jgit.junit:' + VERS,
+  id = 'org.eclipse.jgit:org.eclipse.jgit.junit:' + JGIT_VERS,
   sha1 = 'e8fb1d81f588c3174a9730bdecdbde9faa04140a',
   license = 'DO_NOT_DISTRIBUTE',
   repository = REPO,
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUILD b/lib/jgit/org.eclipse.jgit.junit/BUILD
index d00b82c9..7f31261 100644
--- a/lib/jgit/org.eclipse.jgit.junit/BUILD
+++ b/lib/jgit/org.eclipse.jgit.junit/BUILD
@@ -1,6 +1,15 @@
+load('//tools/bzl:unsign.bzl', 'unsign_jars')
+
 java_library(
-  name = 'junit',
+  name = 'junit-signed',
   exports = ['@jgit_junit//jar'],
   runtime_deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
+)
+
+unsign_jars(
+  name = 'junit',
+  deps = [':junit-signed'],
+    visibility = ['//visibility:public'],
 )
diff --git a/lib/jgit/org.eclipse.jgit/BUCK b/lib/jgit/org.eclipse.jgit/BUCK
index 458703c..74338de 100644
--- a/lib/jgit/org.eclipse.jgit/BUCK
+++ b/lib/jgit/org.eclipse.jgit/BUCK
@@ -3,7 +3,7 @@
 
 maven_jar(
   name = 'jgit',
-  id = 'org.eclipse.jgit:org.eclipse.jgit:' + VERS,
+  id = 'org.eclipse.jgit:org.eclipse.jgit:' + JGIT_VERS,
   bin_sha1 = '3e3d0b73dcf4ad649f37758ea8502d92f3d299de',
   src_sha1 = 'fc352952db91a4046e4b832145eb2dc8afce8db1',
   license = 'jgit',
diff --git a/lib/jgit/org.eclipse.jgit/BUILD b/lib/jgit/org.eclipse.jgit/BUILD
index a1f9cad..bfebb7e 100644
--- a/lib/jgit/org.eclipse.jgit/BUILD
+++ b/lib/jgit/org.eclipse.jgit/BUILD
@@ -1,12 +1,22 @@
+load('//tools/bzl:unsign.bzl', 'unsign_jars')
+
 java_library(
-  name = 'jgit',
+  name = 'jgit-signed',
   exports = ['@jgit//jar'],
   runtime_deps = [':ewah'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-jgit'],
 )
 
 java_library(
   name = 'ewah',
   exports = ['@ewah//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+unsign_jars(
+  name = 'jgit',
+  deps = [':jgit-signed'],
+  visibility = ['//visibility:public'],
 )
diff --git a/lib/joda/BUILD b/lib/joda/BUILD
index a673bf5..ef759d7 100644
--- a/lib/joda/BUILD
+++ b/lib/joda/BUILD
@@ -3,9 +3,11 @@
   exports = ['@joda_time//jar'],
   runtime_deps = ['joda-convert'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'joda-convert',
   exports = ['@joda_convert//jar'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/js.defs b/lib/js.defs
index c9a4256..f215de9 100644
--- a/lib/js.defs
+++ b/lib/js.defs
@@ -124,7 +124,8 @@
   cmds = ['cd $TMP']
   for d in deps:
     cmds.append('unzip -qo $(location %s)' % d)
-  cmds.append('zip -r $OUT bower_components')
+  cmds.append("find bower_components -exec touch -t 198001010000 '{}' ';'")
+  cmds.append('zip -r $OUT bower_components/*')
   return ' && '.join(cmds)
 
 
diff --git a/lib/js/BUCK b/lib/js/BUCK
index 1c46d35..bb31b94 100644
--- a/lib/js/BUCK
+++ b/lib/js/BUCK
@@ -328,10 +328,10 @@
 bower_component(
   name = 'polymer',
   package = 'polymer/polymer',
-  version = '1.4.0',
+  version = '1.7.0',
   deps = [':webcomponentsjs'],
   license = 'polymer',
-  sha1 = 'b84725939ead7c7bdf9917b065f68ef8dc790d06',
+  sha1 = 'e70caa58fdee0ce51c805d548f544f74cc27d143',
 )
 
 bower_component(
diff --git a/lib/js/BUILD b/lib/js/BUILD
new file mode 100644
index 0000000..71fa94f
--- /dev/null
+++ b/lib/js/BUILD
@@ -0,0 +1,34 @@
+package(default_visibility = [ "//visibility:public" ])
+load("//tools/bzl:js.bzl", "bower_component", "js_component")
+
+# For updating the bower versions, run
+#
+#  python tools/js/bower2bazel.py -w lib/js/bower_archives.bzl -b lib/js/bower_components.bzl
+#
+
+# For adding a new component as dependency to a bower_component_bundle
+#
+# 1) add a new bower_archive in WORKSPACE
+#
+# 2) add bower_component(name="my_new_dependency", seed=True) here
+#
+# 3) run bower2bazel (see above.)
+#
+# 4) remove bower_component(name="my_new_dependency", .. ) here
+#
+
+
+load("//lib/js:bower_components.bzl", "define_bower_components")
+define_bower_components()
+
+js_component(
+  name = 'highlightjs',
+  srcs = [ "//lib/highlightjs:highlight.min.js" ],
+  license = '//lib:LICENSE-highlightjs',
+)
+
+filegroup(
+  name = "highlightjs_files",
+  srcs = [ "//lib/highlightjs:highlight.min.js" ],
+  data = ['//lib:LICENSE-highlightjs',],
+)
diff --git a/lib/js/bower_archives.bzl b/lib/js/bower_archives.bzl
new file mode 100644
index 0000000..9c9a5a9
--- /dev/null
+++ b/lib/js/bower_archives.bzl
@@ -0,0 +1,108 @@
+# DO NOT EDIT
+# generated with the following command:
+#
+#   tools/js/bower2bazel.py -w lib/js/bower_archives.bzl -b lib/js/bower_components.bzl
+#
+
+load("//tools/bzl:js.bzl", "bower_archive")
+def load_bower_archives():
+  bower_archive(
+    name = "accessibility-developer-tools",
+    package = "accessibility-developer-tools",
+    version = "2.11.0",
+    sha1 = "792cb24b649dafb316e7e536f8ae65d0d7b52bab")
+  bower_archive(
+    name = "async",
+    package = "async",
+    version = "1.5.2",
+    sha1 = "1ec975d3b3834646a7e3d4b7e68118b90ed72508")
+  bower_archive(
+    name = "chai",
+    package = "chai",
+    version = "3.5.0",
+    sha1 = "849ad3ee7c77506548b7b5db603a4e150b9431aa")
+  bower_archive(
+    name = "iron-a11y-announcer",
+    package = "iron-a11y-announcer",
+    version = "1.0.5",
+    sha1 = "007902c041dd8863a1fe893f62450852f4d8c69b")
+  bower_archive(
+    name = "iron-a11y-keys-behavior",
+    package = "iron-a11y-keys-behavior",
+    version = "1.1.9",
+    sha1 = "f58358ee652c67e6e721364ba50fb77a2ece1465")
+  bower_archive(
+    name = "iron-behaviors",
+    package = "iron-behaviors",
+    version = "1.0.17",
+    sha1 = "47df7e1c2b97978dcafa13edb50fbdb702570acd")
+  bower_archive(
+    name = "iron-fit-behavior",
+    package = "iron-fit-behavior",
+    version = "1.2.5",
+    sha1 = "5938815cd227843fc77ebeac480b999600a76157")
+  bower_archive(
+    name = "iron-flex-layout",
+    package = "iron-flex-layout",
+    version = "1.3.1",
+    sha1 = "ba696394abff5e799fc06eb11bff4720129a1b52")
+  bower_archive(
+    name = "iron-form-element-behavior",
+    package = "iron-form-element-behavior",
+    version = "1.0.6",
+    sha1 = "8d9e6530edc1b99bec1a5c34853911fba3701220")
+  bower_archive(
+    name = "iron-meta",
+    package = "iron-meta",
+    version = "1.1.2",
+    sha1 = "dc22fe05e1cb5f94f30a7193d3433ca1808773b8")
+  bower_archive(
+    name = "iron-resizable-behavior",
+    package = "iron-resizable-behavior",
+    version = "1.0.5",
+    sha1 = "2ebe983377dceb3794dd335131050656e23e2beb")
+  bower_archive(
+    name = "iron-validatable-behavior",
+    package = "iron-validatable-behavior",
+    version = "1.1.1",
+    sha1 = "480423380be0536f948735d91bc472f6e7ced5b4")
+  bower_archive(
+    name = "lodash",
+    package = "lodash",
+    version = "3.10.1",
+    sha1 = "2f207a8293c4c554bf6cf071241f7a00dc513d3a")
+  bower_archive(
+    name = "mocha",
+    package = "mocha",
+    version = "2.5.3",
+    sha1 = "22ef0d1f43ba5e2241369c501ac648f00c0440c0")
+  bower_archive(
+    name = "neon-animation",
+    package = "neon-animation",
+    version = "1.2.4",
+    sha1 = "e8ccbb930c4b7ff470b1450baa901618888a7fd3")
+  bower_archive(
+    name = "sinon-chai",
+    package = "sinon-chai",
+    version = "2.8.0",
+    sha1 = "0464b5d944fdf8116bb23e0b02ecfbac945b3517")
+  bower_archive(
+    name = "sinonjs",
+    package = "sinonjs",
+    version = "1.17.1",
+    sha1 = "a26a6aab7358807de52ba738770f6ac709afd240")
+  bower_archive(
+    name = "stacky",
+    package = "stacky",
+    version = "1.3.2",
+    sha1 = "d6c07a0112ab2e9677fe085933744466a89232fb")
+  bower_archive(
+    name = "web-animations-js",
+    package = "web-animations-js",
+    version = "2.2.2",
+    sha1 = "6276a9f227da7d4ccaf77c202b50e174dd11a2c2")
+  bower_archive(
+    name = "webcomponentsjs",
+    package = "webcomponentsjs",
+    version = "0.7.22",
+    sha1 = "8ba97a4a279ec6973a19b171c462a7b5cf454fb9")
diff --git a/lib/js/bower_components.bzl b/lib/js/bower_components.bzl
new file mode 100644
index 0000000..74515e1
--- /dev/null
+++ b/lib/js/bower_components.bzl
@@ -0,0 +1,221 @@
+# DO NOT EDIT
+# generated with the following command:
+#
+#   tools/js/bower2bazel.py -w lib/js/bower_archives.bzl -b lib/js/bower_components.bzl
+#
+
+load("//tools/bzl:js.bzl", "bower_component")
+def define_bower_components():
+  bower_component(
+    name = "accessibility-developer-tools",
+    license = "//lib:LICENSE-Apache2.0",
+  )
+  bower_component(
+    name = "async",
+    license = "//lib:LICENSE-polymer",
+  )
+  bower_component(
+    name = "chai",
+    license = "//lib:LICENSE-polymer",
+  )
+  bower_component(
+    name = "es6-promise",
+    license = "//lib:LICENSE-polymer",
+    seed = True,
+  )
+  bower_component(
+    name = "fetch",
+    license = "//lib:LICENSE-fetch",
+    seed = True,
+  )
+  bower_component(
+    name = "iron-a11y-announcer",
+    license = "//lib:LICENSE-polymer",
+    deps = [ ":polymer" ],
+  )
+  bower_component(
+    name = "iron-a11y-keys-behavior",
+    license = "//lib:LICENSE-polymer",
+    deps = [ ":polymer" ],
+  )
+  bower_component(
+    name = "iron-autogrow-textarea",
+    license = "//lib:LICENSE-polymer",
+    deps = [
+      ":iron-behaviors",
+      ":iron-flex-layout",
+      ":iron-form-element-behavior",
+      ":iron-validatable-behavior",
+      ":polymer",
+    ],
+    seed = True,
+  )
+  bower_component(
+    name = "iron-behaviors",
+    license = "//lib:LICENSE-polymer",
+    deps = [
+      ":iron-a11y-keys-behavior",
+      ":polymer",
+    ],
+  )
+  bower_component(
+    name = "iron-dropdown",
+    license = "//lib:LICENSE-polymer",
+    deps = [
+      ":iron-a11y-keys-behavior",
+      ":iron-behaviors",
+      ":iron-overlay-behavior",
+      ":iron-resizable-behavior",
+      ":neon-animation",
+      ":polymer",
+    ],
+    seed = True,
+  )
+  bower_component(
+    name = "iron-fit-behavior",
+    license = "//lib:LICENSE-polymer",
+    deps = [ ":polymer" ],
+  )
+  bower_component(
+    name = "iron-flex-layout",
+    license = "//lib:LICENSE-polymer",
+    deps = [ ":polymer" ],
+  )
+  bower_component(
+    name = "iron-form-element-behavior",
+    license = "//lib:LICENSE-polymer",
+    deps = [ ":polymer" ],
+  )
+  bower_component(
+    name = "iron-input",
+    license = "//lib:LICENSE-polymer",
+    deps = [
+      ":iron-a11y-announcer",
+      ":iron-validatable-behavior",
+      ":polymer",
+    ],
+    seed = True,
+  )
+  bower_component(
+    name = "iron-meta",
+    license = "//lib:LICENSE-polymer",
+    deps = [ ":polymer" ],
+  )
+  bower_component(
+    name = "iron-overlay-behavior",
+    license = "//lib:LICENSE-polymer",
+    deps = [
+      ":iron-a11y-keys-behavior",
+      ":iron-fit-behavior",
+      ":iron-resizable-behavior",
+      ":polymer",
+    ],
+    seed = True,
+  )
+  bower_component(
+    name = "iron-resizable-behavior",
+    license = "//lib:LICENSE-polymer",
+    deps = [ ":polymer" ],
+  )
+  bower_component(
+    name = "iron-selector",
+    license = "//lib:LICENSE-polymer",
+    deps = [ ":polymer" ],
+    seed = True,
+  )
+  bower_component(
+    name = "iron-test-helpers",
+    license = "//lib:LICENSE-polymer",
+    deps = [ ":polymer" ],
+    seed = True,
+  )
+  bower_component(
+    name = "iron-validatable-behavior",
+    license = "//lib:LICENSE-polymer",
+    deps = [
+      ":iron-meta",
+      ":polymer",
+    ],
+  )
+  bower_component(
+    name = "lodash",
+    license = "//lib:LICENSE-polymer",
+  )
+  bower_component(
+    name = "mocha",
+    license = "//lib:LICENSE-polymer",
+  )
+  bower_component(
+    name = "moment",
+    license = "//lib:LICENSE-moment",
+    seed = True,
+  )
+  bower_component(
+    name = "neon-animation",
+    license = "//lib:LICENSE-polymer",
+    deps = [
+      ":iron-meta",
+      ":iron-resizable-behavior",
+      ":iron-selector",
+      ":polymer",
+      ":web-animations-js",
+    ],
+  )
+  bower_component(
+    name = "page",
+    license = "//lib:LICENSE-polymer",
+    seed = True,
+  )
+  bower_component(
+    name = "polymer",
+    license = "//lib:LICENSE-polymer",
+    deps = [ ":webcomponentsjs" ],
+    seed = True,
+  )
+  bower_component(
+    name = "promise-polyfill",
+    license = "//lib:LICENSE-polymer",
+    deps = [ ":polymer" ],
+    seed = True,
+  )
+  bower_component(
+    name = "sinon-chai",
+    license = "//lib:LICENSE-polymer",
+  )
+  bower_component(
+    name = "sinonjs",
+    license = "//lib:LICENSE-polymer",
+  )
+  bower_component(
+    name = "stacky",
+    license = "//lib:LICENSE-polymer",
+  )
+  bower_component(
+    name = "test-fixture",
+    license = "//lib:LICENSE-polymer",
+    seed = True,
+  )
+  bower_component(
+    name = "web-animations-js",
+    license = "//lib:LICENSE-Apache2.0",
+  )
+  bower_component(
+    name = "web-component-tester",
+    license = "//lib:LICENSE-polymer",
+    deps = [
+      ":accessibility-developer-tools",
+      ":async",
+      ":chai",
+      ":lodash",
+      ":mocha",
+      ":sinon-chai",
+      ":sinonjs",
+      ":stacky",
+      ":test-fixture",
+    ],
+    seed = True,
+  )
+  bower_component(
+    name = "webcomponentsjs",
+    license = "//lib:LICENSE-polymer",
+  )
diff --git a/lib/log/BUILD b/lib/log/BUILD
index ac92ab6..1e40372 100644
--- a/lib/log/BUILD
+++ b/lib/log/BUILD
@@ -2,6 +2,7 @@
   name = 'api',
   exports = ['@log_api//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-slf4j'],
 )
 
 java_library(
@@ -9,6 +10,7 @@
   exports = ['@log_nop//jar'],
   runtime_deps = [':api'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-slf4j'],
 )
 
 java_library(
@@ -16,18 +18,21 @@
   exports = ['@impl_log4j//jar'],
   runtime_deps = [':log4j'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-slf4j'],
 )
 
 java_library(
   name = 'jcl-over-slf4j',
   exports = ['@jcl_over_slf4j//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-slf4j'],
 )
 
 java_library(
   name = 'log4j',
   exports = ['@log4j//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -38,10 +43,12 @@
     '//lib/commons:lang'
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'json-smart',
   exports = ['@json_smart//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/lucene/BUCK b/lib/lucene/BUCK
index c4a9872..8f2efa2 100644
--- a/lib/lucene/BUCK
+++ b/lib/lucene/BUCK
@@ -1,6 +1,6 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '5.5.0'
+VERSION = '5.5.2'
 
 # core and backward-codecs both provide
 # META-INF/services/org.apache.lucene.codecs.Codec, so they must be merged.
@@ -14,21 +14,32 @@
 )
 
 maven_jar(
-  name = 'lucene-core',
-  id = 'org.apache.lucene:lucene-core:' + VERSION,
-  sha1 = 'a74fd869bb5ad7fe6b4cd29df9543a34aea81164',
+  name = 'lucene-codecs',
+  id = 'org.apache.lucene:lucene-codecs:' + VERSION,
+  sha1 = 'e01fe463d9490bb1b4a6a168e771f7b7255a50b1',
   license = 'Apache2.0',
   exclude = [
     'META-INF/LICENSE.txt',
     'META-INF/NOTICE.txt',
   ],
-  visibility = [],
+)
+
+maven_jar(
+  name = 'lucene-core',
+  id = 'org.apache.lucene:lucene-core:' + VERSION,
+  sha1 = 'de5e5c3161ea01e89f2a09a14391f9b7ed66cdbb',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+  visibility = ['//gerrit-elasticsearch:elasticsearch'],
 )
 
 maven_jar(
   name = 'lucene-analyzers-common',
   id = 'org.apache.lucene:lucene-analyzers-common:' + VERSION,
-  sha1 = '1e0e8243a4410be20c34683034fafa7bb52e55cc',
+  sha1 = 'f0bc3114a6b43f8e64a33c471d5b9e8ddc51564d',
   license = 'Apache2.0',
   deps = [':lucene-core-and-backward-codecs'],
   exclude = [
@@ -40,7 +51,7 @@
 maven_jar(
   name = 'backward-codecs_jar',
   id = 'org.apache.lucene:lucene-backward-codecs:' + VERSION,
-  sha1 = '68480974b2f54f519763632a7c1c5d51cbff3805',
+  sha1 = 'c5cfcd7a8cf48a0144b61fb991c8e50a0bf868d5',
   license = 'Apache2.0',
   deps = [':lucene-core'],
   exclude = [
@@ -51,9 +62,42 @@
 )
 
 maven_jar(
+  name = 'lucene-highlighter',
+  id = 'org.apache.lucene:lucene-highlighter:' + VERSION,
+  sha1 = 'd127ac514e9df965ab0b57d92bbe0c68d3d145b8',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
+  name = 'lucene-join',
+  id = 'org.apache.lucene:lucene-join:'+ VERSION,
+  sha1 = 'dac1b322508f3f2696ecc49a97311d34d8382054',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
+  name = 'lucene-memory',
+  id = 'org.apache.lucene:lucene-memory:' + VERSION,
+  sha1 = '7409db9863d8fbc265c27793c6cc7511304182c2',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
   name = 'lucene-misc',
   id = 'org.apache.lucene:lucene-misc:' + VERSION,
-  sha1 = '504d855a1a38190622fdf990b2298c067e7d60ca',
+  sha1 = '37bbe5a2fb429499dfbe75d750d1778881fff45d',
   license = 'Apache2.0',
   deps = [':lucene-core-and-backward-codecs'],
   exclude = [
@@ -63,9 +107,52 @@
 )
 
 maven_jar(
+  name = 'lucene-sandbox',
+  id = 'org.apache.lucene:lucene-sandbox:' + VERSION,
+  sha1 = '30a91f120706ba66732d5a974b56c6971b3c8a16',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
+  name = 'lucene-spatial',
+  id = 'org.apache.lucene:lucene-spatial:' + VERSION,
+  sha1 = '8ed7a9a43d78222038573dd1c295a61f3c0bb0db',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+maven_jar(
+  name = 'lucene-suggest',
+  id = 'org.apache.lucene:lucene-suggest:' + VERSION,
+  sha1 = 'e8316b37dddcf2092a54dab2ce6aad0d5ad78585',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
+  name = 'lucene-queries',
+  id = 'org.apache.lucene:lucene-queries:' + VERSION,
+  sha1 = '692f1ad887cf4e006a23f45019e6de30f3312d3f',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
   name = 'lucene-queryparser',
   id = 'org.apache.lucene:lucene-queryparser:' + VERSION,
-  sha1 = '0fddc49725b562fd48dff0cff004336ad2a090a4',
+  sha1 = '8ac921563e744463605284c6d9d2d95e1be5b87c',
   license = 'Apache2.0',
   deps = [':lucene-core-and-backward-codecs'],
   exclude = [
@@ -73,3 +160,4 @@
     'META-INF/NOTICE.txt',
   ],
 )
+
diff --git a/lib/lucene/BUILD b/lib/lucene/BUILD
index 679c9f0..4739981 100644
--- a/lib/lucene/BUILD
+++ b/lib/lucene/BUILD
@@ -1,3 +1,4 @@
+package(default_visibility = [ "//visibility:public"])
 load('//tools/bzl:maven.bzl', 'merge_maven_jars')
 
 # core and backward-codecs both provide
@@ -9,6 +10,7 @@
     '@lucene_core//jar',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -16,6 +18,21 @@
   exports = ['@lucene_analyzers_common//jar'],
   runtime_deps = [':lucene-core-and-backward-codecs'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'lucene-codecs',
+  exports = ['@lucene_codecs//jar'],
+  visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'lucene-core',
+  exports = ['@lucene_core//jar'],
+  visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -23,6 +40,7 @@
   exports = ['@lucene_misc//jar'],
   runtime_deps = [':lucene-core-and-backward-codecs'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
@@ -30,4 +48,47 @@
   exports = ['@lucene_queryparser//jar'],
   runtime_deps = [':lucene-core-and-backward-codecs'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'lucene-highlighter',
+  exports = [ '@lucene_highlighter//jar' ],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'lucene-join',
+  exports = [ '@lucene_join//jar' ],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'lucene-memory',
+  exports = [ '@lucene_memory//jar' ],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'lucene-sandbox',
+  exports = [ '@lucene_sandbox//jar' ],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'lucene-spatial',
+  exports = [ '@lucene_spatial//jar' ],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'lucene-suggest',
+  exports = [ '@lucene_suggest//jar' ],
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'lucene-queries',
+  exports = [ '@lucene_queries//jar' ],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/mina/BUILD b/lib/mina/BUILD
index 52468a4..b3ba684 100644
--- a/lib/mina/BUILD
+++ b/lib/mina/BUILD
@@ -3,10 +3,12 @@
   exports = ['@sshd//jar'],
   visibility = ['//visibility:public'],
   runtime_deps = [':core'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'core',
   exports = ['@mina_core//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/openid/BUILD b/lib/openid/BUILD
index 7d97a86..8c5da45 100644
--- a/lib/openid/BUILD
+++ b/lib/openid/BUILD
@@ -9,15 +9,18 @@
     '//lib/guice:guice',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'nekohtml',
   exports = ['@nekohtml//jar'],
   runtime_deps = [':xerces'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'xerces',
   exports = ['@xerces//jar'],
+  data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/ow2/BUCK b/lib/ow2/BUCK
index fabcb25..653bd2b 100644
--- a/lib/ow2/BUCK
+++ b/lib/ow2/BUCK
@@ -1,25 +1,25 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '5.0.3'
+VERSION = '5.1'
 
 maven_jar(
   name = 'ow2-asm',
   id = 'org.ow2.asm:asm:' + VERSION,
-  sha1 = 'dcc2193db20e19e1feca8b1240dbbc4e190824fa',
+  sha1 = '5ef31c4fe953b1fd00b8a88fa1d6820e8785bb45',
   license = 'ow2',
 )
 
 maven_jar(
   name = 'ow2-asm-analysis',
   id = 'org.ow2.asm:asm-analysis:' + VERSION,
-  sha1 = 'c7126aded0e8e13fed5f913559a0dd7b770a10f3',
+  sha1 = '6d1bf8989fc7901f868bee3863c44f21aa63d110',
   license = 'ow2',
 )
 
 maven_jar(
   name = 'ow2-asm-commons',
   id = 'org.ow2.asm:asm-commons:' + VERSION,
-  sha1 = 'a7111830132c7f87d08fe48cb0ca07630f8cb91c',
+  sha1 = '25d8a575034dd9cfcb375a39b5334f0ba9c8474e',
   deps = [':ow2-asm-tree'],
   license = 'ow2',
 )
@@ -27,14 +27,13 @@
 maven_jar(
   name = 'ow2-asm-tree',
   id = 'org.ow2.asm:asm-tree:' + VERSION,
-  sha1 = '287749b48ba7162fb67c93a026d690b29f410bed',
+  sha1 = '87b38c12a0ea645791ead9d3e74ae5268d1d6c34',
   license = 'ow2',
 )
 
 maven_jar(
   name = 'ow2-asm-util',
   id = 'org.ow2.asm:asm-util:' + VERSION,
-  sha1 = '1512e5571325854b05fb1efce1db75fcced54389',
+  sha1 = 'b60e33a6bd0d71831e0c249816d01e6c1dd90a47',
   license = 'ow2',
 )
-
diff --git a/lib/ow2/BUILD b/lib/ow2/BUILD
index 0b99b6f..4c37357 100644
--- a/lib/ow2/BUILD
+++ b/lib/ow2/BUILD
@@ -2,12 +2,14 @@
   name = 'ow2-asm',
   exports = ['@ow2_asm//jar'],
   visibility = ["//visibility:public"],
+  data = ['//lib:LICENSE-ow2'],
 )
 
 java_library(
   name = 'ow2-asm-analysis',
   exports = ['@ow2_asm_analysis//jar'],
   visibility = ["//visibility:public"],
+  data = ['//lib:LICENSE-ow2'],
 )
 
 java_library(
@@ -15,16 +17,19 @@
   exports = ['@ow2_asm_commons//jar'],
   runtime_deps = [':ow2-asm-tree'],
   visibility = ["//visibility:public"],
+  data = ['//lib:LICENSE-ow2'],
 )
 
 java_library(
   name = 'ow2-asm-tree',
   exports = ['@ow2_asm_tree//jar'],
   visibility = ["//visibility:public"],
+  data = ['//lib:LICENSE-ow2'],
 )
 
 java_library(
   name = 'ow2-asm-util',
   exports = ['@ow2_asm_util//jar'],
   visibility = ["//visibility:public"],
+  data = ['//lib:LICENSE-ow2'],
 )
diff --git a/lib/powermock/BUCK b/lib/powermock/BUCK
index b642457..d469a85 100644
--- a/lib/powermock/BUCK
+++ b/lib/powermock/BUCK
@@ -1,12 +1,12 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '1.6.4' # When bumping VERSION, make sure to also move
+VERSION = '1.6.1' # When bumping VERSION, make sure to also move
 # easymock to a compatible version
 
 maven_jar(
   name = 'powermock-module-junit4',
   id = 'org.powermock:powermock-module-junit4:' + VERSION,
-  sha1 = '8692eb1d9bb8eb1310ffe8a20c2da7ee6d1b5994',
+  sha1 = 'ea8530b2848542624f110a393513af397b37b9cf',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-module-junit4-common',
@@ -17,7 +17,7 @@
 maven_jar(
   name = 'powermock-module-junit4-common',
   id = 'org.powermock:powermock-module-junit4-common:' + VERSION,
-  sha1 = 'b0b578da443794ceb8224bd5f5f852aaf40f1b81',
+  sha1 = '7222ced54dabc310895d02e45c5428ca05193cda',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-reflect',
@@ -28,7 +28,7 @@
 maven_jar(
   name = 'powermock-reflect',
   id = 'org.powermock:powermock-reflect:' + VERSION,
-  sha1 = '5532f4e7c42db4bca4778bc9f1afcd4b0ee0b893',
+  sha1 = '97d25eda8275c11161bcddda6ef8beabd534c878',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     '//lib:junit',
@@ -39,7 +39,7 @@
 maven_jar(
   name = 'powermock-api-easymock',
   id = 'org.powermock:powermock-api-easymock:' + VERSION,
-  sha1 = '5c385a0d8c13f84b731b75c6e90319c532f80b45',
+  sha1 = 'aa740ecf89a2f64d410b3d93ef8cd6833009ef00',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-api-support',
@@ -50,7 +50,7 @@
 maven_jar(
   name = 'powermock-api-support',
   id = 'org.powermock:powermock-api-support:' + VERSION,
-  sha1 = '314daafb761541293595630e10a3699ebc07881d',
+  sha1 = '592ee6d929c324109d3469501222e0c76ccf0869',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-core',
@@ -62,7 +62,7 @@
 maven_jar(
   name = 'powermock-core',
   id = 'org.powermock:powermock-core:' + VERSION,
-  sha1 = '85fb32e9ccba748d569fc36aef92e0b9e7f40b87',
+  sha1 = '5afc1efce8d44ed76b30af939657bd598e45d962',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-reflect',
diff --git a/lib/powermock/BUILD b/lib/powermock/BUILD
index 8dc7d23..075b6bf 100644
--- a/lib/powermock/BUILD
+++ b/lib/powermock/BUILD
@@ -6,6 +6,7 @@
     '//lib:junit',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
@@ -16,6 +17,7 @@
     '//lib:junit',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
@@ -26,6 +28,7 @@
     '//lib/easymock:objenesis',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
@@ -36,6 +39,7 @@
     '//lib/easymock:easymock',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
@@ -47,6 +51,7 @@
     '//lib:junit',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
 
 java_library(
@@ -57,4 +62,5 @@
     '//lib:junit',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
diff --git a/lib/prolog/BUCK b/lib/prolog/BUCK
index 77fe5ac..854b0f7 100644
--- a/lib/prolog/BUCK
+++ b/lib/prolog/BUCK
@@ -1,12 +1,12 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '1.4.1'
+VERSION = '1.4.2'
 REPO = GERRIT
 
 maven_jar(
   name = 'runtime',
   id = 'com.googlecode.prolog-cafe:prolog-runtime:' + VERSION,
-  sha1 = 'c5d9f92e49c485969dcd424dfc0c08125b5f8246',
+  sha1 = '4421b4806b6e3a318680f6ab1d57569e857169c6',
   license = 'prologcafe',
   repository = REPO,
 )
@@ -14,7 +14,7 @@
 maven_jar(
   name = 'compiler',
   id = 'com.googlecode.prolog-cafe:prolog-compiler:' + VERSION,
-  sha1 = 'ac24044c6ec166fdcb352b78b80d187ead3eff41',
+  sha1 = '7e5a7ca5efe7db7f69e015cf492f8f04665244d8',
   license = 'prologcafe',
   repository = REPO,
   deps = [
@@ -26,7 +26,7 @@
 maven_jar(
   name = 'io',
   id = 'com.googlecode.prolog-cafe:prolog-io:' + VERSION,
-  sha1 = 'b072426a4b1b8af5e914026d298ee0358a8bb5aa',
+  sha1 = 'd177f6211d1013e0f31a507127f5c87a7f6941f3',
   license = 'prologcafe',
   repository = REPO,
   deps = [':runtime'],
@@ -36,7 +36,7 @@
 maven_jar(
   name = 'cafeteria',
   id = 'com.googlecode.prolog-cafe:prolog-cafeteria:' + VERSION,
-  sha1 = '8cbc3b0c19e7167c42d3f11667b21cb21ddec641',
+  sha1 = '11f396cb2588b65e6a78070488aaa58d12bf000e',
   license = 'prologcafe',
   repository = REPO,
   deps = [
diff --git a/lib/prolog/BUILD b/lib/prolog/BUILD
index 74d8b80..a45cff2 100644
--- a/lib/prolog/BUILD
+++ b/lib/prolog/BUILD
@@ -2,6 +2,7 @@
   name = 'runtime',
   exports = ['@prolog_runtime//jar'],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-prologcafe'],
 )
 
 java_library(
@@ -12,11 +13,13 @@
     ':runtime',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-prologcafe'],
 )
 
 java_library(
   name = 'io',
   exports = ['@prolog_io//jar'],
+  data = ['//lib:LICENSE-prologcafe'],
 )
 
 java_library(
@@ -27,6 +30,7 @@
     'runtime',
   ],
   visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-prologcafe'],
 )
 
 java_binary(
diff --git a/lib/prolog/prolog.bzl b/lib/prolog/prolog.bzl
index 3afb031..7907833 100644
--- a/lib/prolog/prolog.bzl
+++ b/lib/prolog/prolog.bzl
@@ -18,19 +18,19 @@
     name,
     srcs,
     deps = [],
-    visibility = []):
+    **kwargs):
   genrule2(
     name = name + '__pl2j',
     cmd = '$(location //lib/prolog:compiler_bin) ' +
-      '$$TMP $@ ' +
+      '$$(dirname $@) $@ ' +
       '$(SRCS)',
     srcs = srcs,
     tools = ['//lib/prolog:compiler_bin'],
-    out = name + '.srcjar',
+    outs = [ name + '.srcjar' ],
   )
   native.java_library(
     name = name,
     srcs = [':' + name + '__pl2j'],
     deps = ['//lib/prolog:runtime'] + deps,
-    visibility = visibility,
+    **kwargs
   )
diff --git a/plugins/BUILD b/plugins/BUILD
new file mode 100644
index 0000000..2fe6254
--- /dev/null
+++ b/plugins/BUILD
@@ -0,0 +1,22 @@
+load('//tools/bzl:genrule2.bzl', 'genrule2')
+
+CORE = [
+  'commit-message-length-validator',
+  'download-commands',
+  'hooks',
+  'replication',
+  'reviewnotes',
+  'singleusergroup'
+]
+
+genrule2(
+  name = 'core',
+  srcs = ['//plugins/%s:%s_deploy.jar' % (n, n) for n in CORE],
+  cmd = 'mkdir -p $$TMP/WEB-INF/plugins;' +
+    'for s in $(SRCS) ; do ' +
+    'ln -s $$ROOT/$$s $$TMP/WEB-INF/plugins;done;' +
+    'cd $$TMP;' +
+    'zip -qr $$ROOT/$@ .',
+  outs = [ 'core.zip' ],
+  visibility = ['//visibility:public'],
+)
diff --git a/plugins/commit-message-length-validator b/plugins/commit-message-length-validator
index 9b163e1..76b9115 160000
--- a/plugins/commit-message-length-validator
+++ b/plugins/commit-message-length-validator
@@ -1 +1 @@
-Subproject commit 9b163e113de9f3a49219a02d388f7f46ea2559d3
+Subproject commit 76b9115b830cab453c12dd9014f5130c7b7f2ce5
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
index 375de37..0162fc1 160000
--- a/plugins/cookbook-plugin
+++ b/plugins/cookbook-plugin
@@ -1 +1 @@
-Subproject commit 375de37939e2b016d3042c20853ce699d54e7a94
+Subproject commit 0162fc1b0d6f6ef03e818adef7657712314fe14f
diff --git a/plugins/download-commands b/plugins/download-commands
index 7b41f3a..6326db6 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 7b41f3a413b46140b050ae5324cbbcdd467d2b3a
+Subproject commit 6326db67dfa45b13a0c427643bbfa617c18855d7
diff --git a/plugins/hooks b/plugins/hooks
index fa5d24b..f27c7e7 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit fa5d24bbf8c90423a63ac309f42745f7e80e8ddc
+Subproject commit f27c7e7cd865d6638d89d1db130176949c298ce5
diff --git a/plugins/replication b/plugins/replication
index 974dda6..e1beaa8 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 974dda67f9c4282d6d94af4b2c71e08e26534ab1
+Subproject commit e1beaa8e16c05af5538d6459f9e4d3e4af500bca
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 3f3d572..45f6975 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 3f3d572e9618f268b19cc54856deee4c96180e4c
+Subproject commit 45f69757be42ac61dfba915b6e2801525416f1af
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 3ca1167..e985959 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 3ca1167edda713f4bfdcecd9c0e2626797d7027f
+Subproject commit e9859591be48a157c7114d5f3c6acdf27384a408
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
new file mode 100644
index 0000000..4cc6899
--- /dev/null
+++ b/polygerrit-ui/BUILD
@@ -0,0 +1,24 @@
+package(
+  default_visibility=["//visibility:public"]
+)
+
+load("//tools/bzl:js.bzl", "bower_component_bundle")
+load('//tools/bzl:genrule2.bzl', 'genrule2')
+
+bower_component_bundle(
+  name = "polygerrit_components",
+  deps = [
+    '//lib/js:es6-promise',
+    '//lib/js:fetch',
+    # TODO(hanwen): this is inserted separately in the UI zip. Do we need this here?
+    '//lib/js:highlightjs',
+    '//lib/js:iron-autogrow-textarea',
+    '//lib/js:iron-dropdown',
+    '//lib/js:iron-input',
+    '//lib/js:iron-overlay-behavior',
+    '//lib/js:iron-selector',
+    '//lib/js:moment',
+    '//lib/js:page',
+    '//lib/js:polymer',
+    '//lib/js:promise-polyfill',
+])
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 383fb50..1e548d5 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -13,9 +13,21 @@
 All other platforms: [download from
 nodejs.org](https://nodejs.org/en/download/).
 
-## Optional: installing [go](https://golang.org/)
+## Installing [Buck](https://buckbuild.com/)
 
-This is only required for running the ```run-server.sh``` script for testing. See below.
+Follow the instructions
+[here](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_installation)
+to get and install Buck.
+
+## Local UI, Production Data
+
+This is a quick and easy way to test your local changes against real data.
+Unfortunately, you can't sign in, so testing certain features will require
+you to use the "test data" technique described below.
+
+### Installing [go](https://golang.org/)
+
+This is required for running the `run-server.sh` script below.
 
 ```sh
 # Debian/Ubuntu
@@ -27,18 +39,18 @@
 
 All other platforms: [download from golang.org](https://golang.org/)
 
-# Add [go] to your path
+Then add go to your path:
 
 ```
 PATH=$PATH:/usr/local/go/bin
 ```
 
-## Local UI, Production Data
+### Running the server
 
 To test the local UI against gerrit-review.googlesource.com:
 
 ```sh
-./polygerrit-ui/run-server.sh
+./run-server.sh
 ```
 
 Then visit http://localhost:8081
@@ -47,10 +59,8 @@
 
 One-time setup:
 
-1. [Install Buck](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_installation)
-   for building Gerrit.
-2. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_gerrit_development_war_file)
-   and set up a local test site. Docs
+1. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_gerrit_development_war_file)
+2. Set up a local test site. Docs
    [here](https://gerrit-review.googlesource.com/Documentation/install-quick.html) and
    [here](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
 
diff --git a/polygerrit-ui/app/BUCK b/polygerrit-ui/app/BUCK
index d03acf2..6a2b299 100644
--- a/polygerrit-ui/app/BUCK
+++ b/polygerrit-ui/app/BUCK
@@ -10,6 +10,7 @@
   ['**'],
   excludes = [
     'BUCK',
+    'BUILD',
     'index.html',
     'test/**',
   ] + WCT_TEST_PATTERNS + PY_TEST_PATTERNS)
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
new file mode 100644
index 0000000..b6b74b7
--- /dev/null
+++ b/polygerrit-ui/app/BUILD
@@ -0,0 +1,69 @@
+package(
+  default_visibility = ["//visibility:public"])
+
+load('//tools/bzl:genrule2.bzl', 'genrule2')
+load("//tools/bzl:js.bzl", "bower_component_bundle", "vulcanize")
+
+bower_component_bundle(
+  name = 'test_components',
+  deps = [
+    '//polygerrit-ui:polygerrit_components',
+    '//lib/js:iron-test-helpers',
+    '//lib/js:test-fixture',
+    '//lib/js:web-component-tester',
+  ],
+)
+
+vulcanize(
+  name = "gr-app",
+  app = 'elements/gr-app.html',
+  srcs = glob(
+  ['**/*.html', '**/*.js'],
+  exclude = [
+    'bower_components/**',
+    'index.html',
+    'test/**',
+    '**/*_test.html',
+    ]),
+  deps = [ "//polygerrit-ui:polygerrit_components"],
+)
+
+filegroup(
+  name = "top_sources",
+  srcs = glob([
+    'favicon.ico',
+    'index.html',
+  ]),
+)
+
+filegroup(
+  name = "css_sources",
+  srcs = glob(['styles/**/*.css'])
+)
+
+genrule2(
+  name = "polygerrit_ui",
+  cmd = " && ".join([
+    "mkdir -p $$TMP/polygerrit_ui/{styles,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
+    "cp $(locations :gr-app) $$TMP/polygerrit_ui/elements/",
+    "cp $(locations //lib/fonts:sourcecodepro) $$TMP/polygerrit_ui/fonts/",
+    "for f in $(locations :top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
+    "for f in $(locations :css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
+    "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
+    "unzip -qd $$TMP/polygerrit_ui/bower_components $(location @webcomponentsjs//:zipfile) webcomponentsjs/webcomponents-lite.js",
+    "cd $$TMP",
+    "find . -exec touch -t 198001010000 '{}' ';'",
+    "zip -qr $$ROOT/$@ *",
+  ]),
+  srcs = [
+    "//lib/fonts:sourcecodepro",
+    "//lib/js:highlightjs_files",
+    ":top_sources",
+    ":css_sources",
+    ":gr-app",
+    # we extract from the zip, but depend on the component for license checking.
+    "@webcomponentsjs//:zipfile",
+    "//lib/js:webcomponentsjs",
+  ],
+  outs = [ "polygerrit_ui.zip" ],
+)
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
new file mode 100644
index 0000000..fa8289f
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
@@ -0,0 +1,61 @@
+<!--
+Copyright (C) 2016 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.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  /** @polymerBehavior Gerrit.PathListBehavior */
+  var PathListBehavior = {
+    specialFilePathCompare: function(a, b) {
+      var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+      // The commit message always goes first.
+      if (a === COMMIT_MESSAGE_PATH) {
+        return -1;
+      }
+      if (b === COMMIT_MESSAGE_PATH) {
+        return 1;
+      }
+
+      var aLastDotIndex = a.lastIndexOf('.');
+      var aExt = a.substr(aLastDotIndex + 1);
+      var aFile = a.substr(0, aLastDotIndex) || a;
+
+      var bLastDotIndex = b.lastIndexOf('.');
+      var bExt = b.substr(bLastDotIndex + 1);
+      var bFile = b.substr(0, bLastDotIndex) || b;
+
+      // Sort header files above others with the same base name.
+      var headerExts = ['h', 'hxx', 'hpp'];
+      if (aFile.length > 0 && aFile === bFile) {
+        if (headerExts.indexOf(aExt) !== -1 &&
+            headerExts.indexOf(bExt) !== -1) {
+          return a.localeCompare(b);
+        }
+        if (headerExts.indexOf(aExt) !== -1) {
+          return -1;
+        }
+        if (headerExts.indexOf(bExt) !== -1) {
+          return 1;
+        }
+      }
+      return aFile.localeCompare(bFile) || a.localeCompare(b);
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.PathListBehavior = PathListBehavior;
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
new file mode 100644
index 0000000..adf0bf1
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
@@ -0,0 +1,39 @@
+<!--
+Copyright (C) 2016 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.
+-->
+<!-- Polymer included for the html import polyfill. -->
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<title>gr-path-list-behavior</title>
+
+<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-path-list-behavior.html">
+
+<script>
+  suite('gr-path-list-behavior tests', function() {
+    test('special sort', function() {
+      var sort = Gerrit.PathListBehavior.specialFilePathCompare;
+      var testFiles = [
+        '/a.h',
+        '/a.cpp',
+        '/COMMIT_MSG',
+        '/asdasd',
+        '/mrPeanutbutter.py'
+      ];
+      assert.deepEqual(testFiles.sort(sort),
+          ['/COMMIT_MSG', '/a.h', '/a.cpp', '/asdasd', '/mrPeanutbutter.py']);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
index 3702c84..c910d8f 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
@@ -22,6 +22,12 @@
     properties: {
       hasTooltip: Boolean,
 
+      _isTouchDevice: {
+        type: Boolean,
+        value: function() {
+          return 'ontouchstart' in document.documentElement;
+        },
+      },
       _tooltip: Element,
       _titleText: String,
     },
@@ -29,10 +35,10 @@
     attached: function() {
       if (!this.hasTooltip) { return; }
 
-      this.addEventListener('mouseover', this._handleShowTooltip.bind(this));
-      this.addEventListener('mouseout', this._handleHideTooltip.bind(this));
-      this.addEventListener('focusin', this._handleShowTooltip.bind(this));
-      this.addEventListener('focusout', this._handleHideTooltip.bind(this));
+      this.addEventListener('mouseenter', this._handleShowTooltip.bind(this));
+      this.addEventListener('mouseleave', this._handleHideTooltip.bind(this));
+      this.addEventListener('tap', this._handleHideTooltip.bind(this));
+
       this.listen(window, 'scroll', '_handleWindowScroll');
     },
 
@@ -41,6 +47,8 @@
     },
 
     _handleShowTooltip: function(e) {
+      if (this._isTouchDevice) { return; }
+
       if (!this.hasAttribute('title') ||
           this.getAttribute('title') === '' ||
           this._tooltip) {
@@ -66,9 +74,11 @@
     },
 
     _handleHideTooltip: function(e) {
+      if (this._isTouchDevice) { return; }
       if (!this.hasAttribute('title') ||
-          this._titleText == null ||
-          this === document.activeElement) { return; }
+          this._titleText == null) {
+        return;
+      }
 
       this.setAttribute('title', this._titleText);
       if (this._tooltip && this._tooltip.parentNode) {
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
new file mode 100644
index 0000000..b7d71fc
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
@@ -0,0 +1,42 @@
+<!--
+Copyright (C) 2016 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.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  /** @polymerBehavior Gerrit.URLEncodingBehavior */
+  var URLEncodingBehavior = {
+    /**
+     * Pretty-encodes a URL. Double-encodes the string, and then replaces
+     *   benevolent characters for legibility.
+     */
+    encodeURL: function(url, replaceSlashes) {
+      // @see Issue 4255 regarding double-encoding.
+      var output = encodeURIComponent(encodeURIComponent(url));
+      // @see Issue 4577 regarding more readable URLs.
+      output = output.replace(/%253A/g, ':');
+      output = output.replace(/%2520/g, '+');
+      if (replaceSlashes) {
+        output = output.replace(/%252F/g, '/');
+      }
+      return output;
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.URLEncodingBehavior = URLEncodingBehavior;
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
index 17acac8..f636650 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
@@ -20,7 +20,9 @@
 
   /** @polymerBehavior Gerrit.KeyboardShortcutBehavior */
   var KeyboardShortcutBehavior = {
-    enabled: true,
+    // Set of identifiers currently blocking keyboard shortcuts. Stored as
+    // a map of string to the value of true.
+    _disablers: {},
 
     properties: {
       keyEventTarget: {
@@ -43,8 +45,12 @@
       this.keyEventTarget.removeEventListener('keydown', this._boundKeyHandler);
     },
 
-    shouldSupressKeyboardShortcut: function(e) {
-      if (!KeyboardShortcutBehavior.enabled) { return true; }
+    shouldSuppressKeyboardShortcut: function(e) {
+      for (var c in KeyboardShortcutBehavior._disablers) {
+        if (KeyboardShortcutBehavior._disablers[c] === true) {
+          return true;
+        }
+      }
       var getModifierState = e.getModifierState ?
           e.getModifierState.bind(e) :
           function() { return false; };
@@ -60,6 +66,14 @@
              target.tagName == 'A' ||
              target.tagName == 'GR-BUTTON';
     },
+
+    disable: function(id) {
+      KeyboardShortcutBehavior._disablers[id] = true;
+    },
+
+    enable: function(id) {
+      delete KeyboardShortcutBehavior._disablers[id];
+    },
   };
 
   window.Gerrit = window.Gerrit || {};
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior_test.html
new file mode 100644
index 0000000..5ec4145
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior_test.html
@@ -0,0 +1,78 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>keyboard-shortcut-behavior</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="keyboard-shortcut-behavior.html">
+
+<test-fixture id="basic">
+  <template>
+    <test-element></test-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('keyboard-shortcut-behavior tests', function() {
+    var element;
+
+    suiteSetup(function() {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'test-element',
+        behaviors: [Gerrit.KeyboardShortcutBehavior],
+        properties: {
+          keyEventTarget: {
+            value: function() { return document.body; },
+          },
+          log: {
+            value: function() { return []; },
+          },
+        },
+
+        _handleKey: function(e) {
+          if (!this.shouldSuppressKeyboardShortcut(e)) {
+            this.log.push(e.keyCode);
+          }
+        },
+      });
+    });
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('blocks keydown events iff one or more disablers', function() {
+      MockInteractions.pressAndReleaseKeyOn(document.body, 97);  // 'a'
+      Gerrit.KeyboardShortcutBehavior.enable('x');  // should have no effect
+      MockInteractions.pressAndReleaseKeyOn(document.body, 98);  // 'b'
+      Gerrit.KeyboardShortcutBehavior.disable('x');  // blocking starts here
+      MockInteractions.pressAndReleaseKeyOn(document.body, 99);  // 'c'
+      Gerrit.KeyboardShortcutBehavior.disable('y');
+      MockInteractions.pressAndReleaseKeyOn(document.body, 100);  // 'd'
+      Gerrit.KeyboardShortcutBehavior.enable('x');
+      MockInteractions.pressAndReleaseKeyOn(document.body, 101);  // 'e'
+      Gerrit.KeyboardShortcutBehavior.enable('y');  // blocking ends here
+      MockInteractions.pressAndReleaseKeyOn(document.body, 102);  // 'f'
+      assert.deepEqual(element.log, [97, 98, 102]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior.html
index 4def9b2..b7cf467 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior.html
@@ -81,7 +81,13 @@
       COMMIT_FOOTERS: 17,
 
       // Include push certificate information along with any patch sets.
-      PUSH_CERTIFICATES: 18
+      PUSH_CERTIFICATES: 18,
+
+      // Include change's reviewer updates.
+      REVIEWER_UPDATES: 19,
+
+      // Set the submittable boolean.
+      SUBMITTABLE: 20
     },
 
     listChangesOptionsToHex: function() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
index 9126785..df1ade3 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
@@ -14,9 +14,10 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-change-list-styles.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index 90b2e1d..275a8cd 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -44,6 +44,7 @@
 
     behaviors: [
       Gerrit.RESTClientBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     _computeChangeURL: function(changeNum) {
@@ -108,11 +109,14 @@
     },
 
     _computeProjectURL: function(project) {
-      return '/q/status:open+project:' + project;
+      return '/q/status:open+project:' +
+          this.encodeURL(project, false);
     },
 
     _computeProjectBranchURL: function(project, branch) {
-      return '/q/status:open+project:' + project + '+branch:' + branch;
+      // @see Issue 4255.
+      return this._computeProjectURL(project) +
+          '+branch:' + this.encodeURL(branch, false);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
index b7c0853..b66c70b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
@@ -35,6 +35,10 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
       element = fixture('basic');
     });
 
@@ -120,12 +124,12 @@
       assert.equal(element._computeLabelValue(
           {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
 
-      assert.equal(element._computeProjectURL('combustible-stuff'),
-          '/q/status:open+project:combustible-stuff');
+      assert.equal(element._computeProjectURL('combustible/stuff'),
+          '/q/status:open+project:combustible%252Fstuff');
 
       assert.equal(element._computeProjectBranchURL(
-          'combustible-stuff', 'lemons'),
-          '/q/status:open+project:combustible-stuff+branch:lemons');
+          'combustible-stuff', 'le/mons'),
+          '/q/status:open+project:combustible-stuff+branch:le%252Fmons');
 
       element.change = {_number: 42};
       assert.equal(element.changeURL, '/c/42/');
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
index 1f06dff..91b2f07 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-change-list/gr-change-list.html">
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index 7fbe455..45d9a57 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -23,6 +23,7 @@
      * @event title-change
      */
 
+    behaviors: [Gerrit.URLEncodingBehavior],
     properties: {
       /**
        * URL params passed from the router.
@@ -116,7 +117,8 @@
       // Offset could be a string when passed from the router.
       offset = +(offset || 0);
       var newOffset = Math.max(0, offset + (changesPerPage * direction));
-      var href = '/q/' + query;
+      // Double encode URI component.
+      var href = '/q/' + this.encodeURL(query, false);
       if (newOffset > 0) {
         href += ',' + newOffset;
       }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
new file mode 100644
index 0000000..944e963
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-list-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-change-list-view.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-list-view></gr-change-list-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-list-view tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
+      element = fixture('basic');
+    });
+
+    test('url is properly encoded', function() {
+      assert.equal(element._computeNavLink(
+          'status:open project:platform/frameworks/base', 0, -1, 25),
+          '/q/status:open+project:platform%252Fframeworks%252Fbase'
+      );
+      assert.equal(element._computeNavLink(
+          'status:open project:platform/frameworks/base', 0, 1, 25),
+          '/q/status:open+project:platform%252Fframeworks%252Fbase,25'
+      );
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index 4e17253..2be0afc 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -150,7 +150,7 @@
     },
 
     _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       if (this.groups == null) { return; }
       var len = 0;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index aa77b77..718cfb5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -21,7 +21,6 @@
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../../../bower_components/page/page.js"></script>
-<script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-change-list.html">
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
index 98f2b18..588fff5 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
@@ -52,7 +52,8 @@
         change="[[change]]"
         filter="[[filter]]"
         placeholder="[[placeholder]]"
-        on-add="_handleAdd">
+        on-add="_handleAdd"
+        on-input-keydown="_handleInputKeydown">
     </gr-account-entry>
   </template>
   <script src="gr-account-list.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
index 87d7116..2e3200f 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
@@ -37,6 +37,10 @@
       'remove': '_handleRemove',
     },
 
+    get accountChips() {
+      return Polymer.dom(this.root).querySelectorAll('gr-account-chip');
+    },
+
     get focusStart() {
       return this.$.entry.focusStart;
     },
@@ -104,8 +108,20 @@
           e.detail.account);
     },
 
+    _handleInputKeydown: function(e) {
+      var input = e.detail.input;
+      if (input.selectionStart !== input.selectionEnd ||
+          input.selectionStart !== 0) {
+        return;
+      }
+      switch (e.detail.keyCode) {
+        case 8: // Backspace
+          this.splice('accounts', this.accounts.length - 1, 1);
+          break;
+      }
+    },
+
     additions: function() {
-      var result = [];
       return this.accounts.filter(function(account) {
         return account._pendingAdd;
       }).map(function(account) {
@@ -115,7 +131,6 @@
           return {account: account};
         }
       });
-      return result;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
index bb55d08..cbfe1ff 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
@@ -20,7 +20,6 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-<script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-account-list.html">
@@ -59,14 +58,11 @@
       existingReviewer1 = makeAccount();
       existingReviewer2 = makeAccount();
 
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
       element = fixture('basic');
       element.accounts = [existingReviewer1, existingReviewer2];
-
-      stub('gr-rest-api-interface', {
-        getConfig: function() {
-          return Promise.resolve({});
-        },
-      });
     });
 
     test('account entry only appears when editable', function() {
@@ -232,5 +228,34 @@
         },
       ]);
     });
+
+    suite('keyboard interactions', function() {
+      var sandbox;
+      setup(function() {
+        sandbox = sinon.sandbox.create();
+      });
+
+      teardown(function() {
+        sandbox.restore();
+      });
+
+      test('backspace from input removes account iff cursor is in start pos',
+          function(done) {
+        var input = element.$.entry.$.input;
+        sandbox.stub(element.$.entry, '_getReviewerSuggestions');
+        sandbox.stub(input, '_updateSuggestions');
+        input.text = 'test';
+        MockInteractions.focus(input.$.input);
+        flush(function() {
+          assert.equal(element.accounts.length, 2);
+          MockInteractions.pressAndReleaseKeyOn(input.$.input, 8); // Backspace
+          assert.equal(element.accounts.length, 2);
+          input.text = '';
+          MockInteractions.pressAndReleaseKeyOn(input.$.input, 8); // Backspace
+          assert.equal(element.accounts.length, 1);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index b741784..96c6193 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -32,19 +32,14 @@
   <template>
     <style>
       :host {
-        display: block;
+        display: inline-block;
+        font-family: var(--font-family);
       }
       section {
-        margin-top: 1em;
-      }
-      .groupLabel {
-        color: #666;
-        margin-bottom: .15em;
-        text-align: center;
+        display: inline-block;
       }
       gr-button {
-        display: block;
-        margin-bottom: .5em;
+        margin-left: .5em;
       }
       gr-button:before {
         content: attr(data-label);
@@ -53,6 +48,15 @@
         content: attr(data-loading-label);
       }
       @media screen and (max-width: 50em) {
+        :host,
+        section,
+        gr-button {
+          display: block;
+        }
+        gr-button {
+          margin-bottom: .5em;
+          margin-left: 0;
+        }
         .confirmDialog {
           width: 90vw;
         }
@@ -60,9 +64,9 @@
     </style>
     <div>
       <section hidden$="[[!_actionCount(actions.*, _additionalActions.*)]]">
-        <div class="groupLabel">Change</div>
         <template is="dom-repeat" items="[[_changeActionValues]]" as="action">
           <gr-button title$="[[action.title]]"
+              hidden$="[[_computeActionHidden(action.__key, _hiddenChangeActions.*)]]"
               primary$="[[action.__primary]]"
               hidden$="[[!action.enabled]]"
               data-action-key$="[[action.__key]]"
@@ -73,9 +77,9 @@
         </template>
       </section>
       <section hidden$="[[!_actionCount(_revisionActions.*, _additionalActions.*)]]">
-        <div class="groupLabel">Revision</div>
         <template is="dom-repeat" items="[[_revisionActionValues]]" as="action">
           <gr-button title$="[[action.title]]"
+              hidden$="[[_computeActionHidden(action.__key, _hiddenRevisionActions.*)]]"
               primary$="[[action.__primary]]"
               disabled$="[[!action.enabled]]"
               data-action-key$="[[action.__key]]"
@@ -94,13 +98,12 @@
           hidden></gr-confirm-rebase-dialog>
       <gr-confirm-cherrypick-dialog id="confirmCherrypick"
           class="confirmDialog"
-          commit-info="[[commitInfo]]"
+          message="[[commitMessage]]"
           on-confirm="_handleCherrypickConfirm"
           on-cancel="_handleConfirmDialogCancel"
           hidden></gr-confirm-cherrypick-dialog>
       <gr-confirm-revert-dialog id="confirmRevertDialog"
           class="confirmDialog"
-          commit-info="[[commitInfo]]"
           on-confirm="_handleRevertDialogConfirm"
           on-cancel="_handleConfirmDialogCancel"
           hidden></gr-confirm-revert-dialog>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 3445f4e..9fdc2a9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -75,7 +75,10 @@
       },
       changeNum: String,
       patchNum: String,
-      commitInfo: Object,
+      commitMessage: {
+        type: String,
+        value: '',
+      },
 
       _loading: {
         type: Boolean,
@@ -99,6 +102,14 @@
         type: Array,
         value: function() { return []; },
       },
+      _hiddenChangeActions: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _hiddenRevisionActions: {
+        type: Array,
+        value: function() { return []; },
+      },
     },
 
     ActionType: ActionType,
@@ -166,6 +177,24 @@
       ], value);
     },
 
+    setActionHidden: function(type, key, hidden) {
+      var path;
+      if (type === ActionType.CHANGE) {
+        path = '_hiddenChangeActions';
+      } else if (type === ActionType.REVISION) {
+        path = '_hiddenRevisionActions';
+      } else {
+        throw Error('Invalid action type given: ' + type);
+      }
+
+      var idx = this.get(path).indexOf(key);
+      if (hidden && idx === -1) {
+        this.push(path, key);
+      } else if (!hidden && idx !== -1) {
+        this.splice(path, idx, 1);
+      }
+    },
+
     _indexOfActionButtonWithKey: function(key) {
       for (var i = 0; i < this._additionalActions.length; i++) {
         if (this._additionalActions[i].__key === key) {
@@ -208,13 +237,13 @@
     _computeRevisionActionValues: function(actionsChangeRecord,
         primariesChangeRecord, additionalActionsChangeRecord) {
       return this._getActionValues(actionsChangeRecord, primariesChangeRecord,
-          additionalActionsChangeRecord, 'revision');
+          additionalActionsChangeRecord, ActionType.REVISION);
     },
 
     _computeChangeActionValues: function(actionsChangeRecord,
         primariesChangeRecord, additionalActionsChangeRecord) {
       return this._getActionValues(actionsChangeRecord, primariesChangeRecord,
-          additionalActionsChangeRecord, 'change');
+          additionalActionsChangeRecord, ActionType.CHANGE);
     },
 
     _getActionValues: function(actionsChangeRecord, primariesChangeRecord,
@@ -231,6 +260,15 @@
         actions[a].__key = a;
         actions[a].__type = type;
         actions[a].__primary = primaryActionKeys.indexOf(a) !== -1;
+        if (actions[a].label === 'Delete') {
+          // This label is common within change and revision actions. Make it
+          // more explicit to the user.
+          if (type === ActionType.CHANGE) {
+            actions[a].label += ' Change';
+          } else if (type === ActionType.REVISION) {
+            actions[a].label += ' Revision';
+          }
+        }
         // Triggers a re-render by ensuring object inequality.
         // TODO(andybons): Polyfill for Object.assign.
         result.push(Object.assign({}, actions[a]));
@@ -254,12 +292,37 @@
     },
 
     _canSubmitChange: function() {
-      return this.$.jsAPI.canSubmitChange();
+      return this.$.jsAPI.canSubmitChange(this.change,
+          this._getRevision(this.change, this.patchNum));
+    },
+
+    _computeActionHidden: function(key, hiddenActionsChangeRecord) {
+      var hiddenActions =
+          (hiddenActionsChangeRecord && hiddenActionsChangeRecord.base) || [];
+      return hiddenActions.indexOf(key) !== -1;
+    },
+
+    _getRevision: function(change, patchNum) {
+      var num = window.parseInt(patchNum, 10);
+      for (var hash in change.revisions) {
+        var rev = change.revisions[hash];
+        if (rev._number === num) {
+          return rev;
+        }
+      }
+      return null;
     },
 
     _modifyRevertMsg: function() {
       return this.$.jsAPI.modifyRevertMsg(this.change,
-                                          this.$.confirmRevertDialog.message);
+          this.$.confirmRevertDialog.message, this.commitMessage);
+    },
+
+    showRevertDialog: function() {
+      this.$.confirmRevertDialog.populateRevertMessage(
+          this.commitMessage, this.change.current_revision);
+      this.$.confirmRevertDialog.message = this._modifyRevertMsg();
+      this._showActionDialog(this.$.confirmRevertDialog);
     },
 
     _handleActionTap: function(e) {
@@ -274,9 +337,7 @@
       if (type === ActionType.REVISION) {
         this._handleRevisionAction(key);
       } else if (key === ChangeActions.REVERT) {
-        this.$.confirmRevertDialog.populateRevertMessage();
-        this.$.confirmRevertDialog.message = this._modifyRevertMsg();
-        this._showActionDialog(this.$.confirmRevertDialog);
+        this.showRevertDialog();
       } else if (key === ChangeActions.ABANDON) {
         this._showActionDialog(this.$.confirmAbandonDialog);
       } else {
@@ -290,6 +351,7 @@
           this._showActionDialog(this.$.confirmRebase);
           break;
         case RevisionActions.CHERRYPICK:
+          this.$.confirmCherrypick.branch = '';
           this._showActionDialog(this.$.confirmCherrypick);
           break;
         case RevisionActions.SUBMIT:
@@ -308,6 +370,10 @@
     },
 
     _handleConfirmDialogCancel: function() {
+      this._hideAllDialogs();
+    },
+
+    _hideAllDialogs: function() {
       var dialogEls =
           Polymer.dom(this.root).querySelectorAll('.confirmDialog');
       for (var i = 0; i < dialogEls.length; i++) {
@@ -331,7 +397,7 @@
         payload.base = el.base;
       }
       this.$.overlay.close();
-      el.hidden = false;
+      el.hidden = true;
       this._fireAction('/rebase', this._revisionActions.rebase, true, payload);
     },
 
@@ -347,7 +413,7 @@
         return;
       }
       this.$.overlay.close();
-      el.hidden = false;
+      el.hidden = true;
       this._fireAction(
           '/cherrypick',
           this._revisionActions.cherrypick,
@@ -362,7 +428,7 @@
     _handleRevertDialogConfirm: function() {
       var el = this.$.confirmRevertDialog;
       this.$.overlay.close();
-      el.hidden = false;
+      el.hidden = true;
       this._fireAction('/revert', this.actions.revert, false,
           {message: el.message});
     },
@@ -370,7 +436,7 @@
     _handleAbandonDialogConfirm: function() {
       var el = this.$.confirmAbandonDialog;
       this.$.overlay.close();
-      el.hidden = false;
+      el.hidden = true;
       this._fireAction('/abandon', this.actions.abandon, false,
           {message: el.message});
     },
@@ -393,14 +459,32 @@
     },
 
     _showActionDialog: function(dialog) {
+      this._hideAllDialogs();
+
       dialog.hidden = false;
-      this.$.overlay.open();
+      this.$.overlay.open().then(function() {
+        if (dialog.resetFocus) {
+          dialog.resetFocus();
+        }
+      });
+    },
+
+    // TODO(rmistry): Redo this after
+    // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
+    _setLabelValuesOnRevert: function(newChangeId) {
+      var labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
+      if (labels) {
+        var url = '/changes/' + newChangeId + '/revisions/current/review';
+        this.$.restAPI.send(this.actions.revert.method, url, {labels: labels});
+      }
     },
 
     _handleResponse: function(action, response) {
       return this.$.restAPI.getResponseObject(response).then(function(obj) {
         switch (action.__key) {
           case ChangeActions.REVERT:
+            this._setLabelValuesOnRevert(obj.change_id);
+            /* falls through */
           case RevisionActions.CHERRYPICK:
             page.show(this.changePath(obj._number));
             break;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 80aaf3b..c180f46 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -38,23 +38,30 @@
       stub('gr-rest-api-interface', {
         getChangeRevisionActions: function() {
           return Promise.resolve({
+            '/': {
+              method: 'DELETE',
+              label: 'Delete',
+              title: 'Delete draft revision 2',
+              enabled: true,
+            },
             cherrypick: {
               method: 'POST',
               label: 'Cherry Pick',
               title: 'Cherry pick change to a different branch',
-              enabled: true
+              enabled: true,
             },
             rebase: {
               method: 'POST',
               label: 'Rebase',
-              title: 'Rebase onto tip of branch or parent change'
+              title: 'Rebase onto tip of branch or parent change',
+              enabled: true,
             },
             submit: {
               method: 'POST',
               label: 'Submit',
-              title: 'Submit patch set 1 into master',
-              enabled: true
-            }
+              title: 'Submit patch set 2 into master',
+              enabled: true,
+            },
           });
         },
         send: function(method, url, payload) {
@@ -79,19 +86,123 @@
       element = fixture('basic');
       element.changeNum = '42';
       element.patchNum = '2';
+      element.actions = {
+        '/': {
+          method: 'DELETE',
+          label: 'Delete',
+          title: 'Delete draft change 42',
+          enabled: true
+        },
+      };
       return element.reload();
     });
 
-    test('submit, rebase, and cherry-pick buttons show', function(done) {
+    test('hide revision action', function(done) {
+      flush(function() {
+        var buttonEl = element.$$('[data-action-key="submit"]');
+        assert.isOk(buttonEl);
+        assert.isFalse(buttonEl.hasAttribute('hidden'));
+        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenRevisionActions, 1);
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenRevisionActions, 1);
+        flush(function() {
+          var buttonEl = element.$$('[data-action-key="submit"]');
+          assert.isOk(buttonEl);
+          assert.isTrue(buttonEl.hasAttribute('hidden'));
+
+          element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, false);
+          flush(function() {
+            var buttonEl = element.$$('[data-action-key="submit"]');
+            assert.isOk(buttonEl);
+            assert.isFalse(buttonEl.hasAttribute('hidden'));
+            done();
+          });
+        });
+      });
+    });
+
+    test('hide change action', function(done) {
+      flush(function() {
+        var buttonEl = element.$$('[data-action-key="/"]');
+        assert.isOk(buttonEl);
+        assert.isFalse(buttonEl.hasAttribute('hidden'));
+        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
+        element.setActionHidden(element.ActionType.CHANGE,
+            element.ChangeActions.DELETE, true);
+        assert.lengthOf(element._hiddenChangeActions, 1);
+        element.setActionHidden(element.ActionType.CHANGE,
+            element.ChangeActions.DELETE, true);
+        assert.lengthOf(element._hiddenChangeActions, 1);
+        flush(function() {
+          var buttonEl = element.$$('[data-action-key="/"]');
+          assert.isOk(buttonEl);
+          assert.isTrue(buttonEl.hasAttribute('hidden'));
+
+          element.setActionHidden(element.ActionType.CHANGE,
+            element.RevisionActions.DELETE, false);
+          flush(function() {
+            var buttonEl = element.$$('[data-action-key="/"]');
+            assert.isOk(buttonEl);
+            assert.isFalse(buttonEl.hasAttribute('hidden'));
+            done();
+          });
+        });
+      });
+    });
+
+    test('buttons show', function(done) {
       flush(function() {
         var buttonEls = Polymer.dom(element.root).querySelectorAll('gr-button');
-        assert.equal(buttonEls.length, 3);
+        assert.equal(buttonEls.length, 5);
         assert.isFalse(element.hidden);
         done();
       });
     });
 
+    test('delete buttons have explicit labels', function(done) {
+      flush(function() {
+        var buttonEls =
+            Polymer.dom(element.root).querySelectorAll('[data-action-key="/"]');
+        assert.equal(buttonEls.length, 2);
+        assert.notEqual(buttonEls[0].getAttribute('data-label'),
+            buttonEls[1].getAttribute['data-label']);
+        assert.isTrue(
+            buttonEls[0].getAttribute('data-label') === 'Delete Revision' ||
+            buttonEls[0].getAttribute('data-label') === 'Delete Change'
+        );
+        assert.isTrue(
+            buttonEls[1].getAttribute('data-label') === 'Delete Revision' ||
+            buttonEls[1].getAttribute('data-label') === 'Delete Change'
+        );
+        done();
+      });
+    });
+
+    test('get revision object from change', function() {
+      var revObj = {_number: 2, foo: 'bar'};
+      var change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: revObj,
+        },
+      };
+      assert.deepEqual(element._getRevision(change, '2'), revObj);
+    });
+
     test('submit change', function(done) {
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+        },
+      };
+      element.patchNum = '2';
+
       flush(function() {
         var submitButton = element.$$('gr-button[data-action-key="submit"]');
         assert.ok(submitButton);
@@ -129,6 +240,7 @@
           __key: 'rebase',
           __type: 'revision',
           __primary: false,
+          enabled: true,
           label: 'Rebase',
           method: 'POST',
           title: 'Rebase onto tip of branch or parent change',
@@ -154,6 +266,25 @@
       });
     });
 
+    test('two dialogs are not shown at the same time', function(done) {
+      flush(function() {
+        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+        assert.ok(rebaseButton);
+        MockInteractions.tap(rebaseButton);
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.confirmRebase.hidden);
+
+        var cherryPickButton =
+            element.$$('gr-button[data-action-key="cherrypick"]');
+        assert.ok(cherryPickButton);
+        MockInteractions.tap(cherryPickButton);
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.confirmRebase.hidden);
+        assert.isFalse(element.$.confirmCherrypick.hidden);
+        done();
+      });
+    });
+
     suite('cherry-pick', function() {
       var fireActionStub;
       var alertStub;
@@ -169,8 +300,9 @@
       });
 
       test('works', function() {
-        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
-        MockInteractions.tap(rebaseButton);
+        var cherryPickButton =
+            element.$$('gr-button[data-action-key="cherrypick"]');
+        MockInteractions.tap(cherryPickButton);
         var action = {
           __key: 'cherrypick',
           __type: 'revision',
@@ -198,6 +330,16 @@
           }
         ]);
       });
+
+      test('branch name cleared when re-open cherrypick', function() {
+        var cherryPickButton =
+            element.$$('gr-button[data-action-key="cherrypick"]');
+        var emptyBranchName = '';
+        element.$.confirmCherrypick.branch = 'master';
+
+        MockInteractions.tap(cherryPickButton);
+        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+      });
     });
 
     test('custom actions', function(done) {
@@ -241,6 +383,9 @@
       });
 
       test('revert change with plugin hook', function(done) {
+        element.change = {
+          current_revision: 'abc1234',
+        };
         var newRevertMsg = 'Modified revert msg';
         var modifyRevertMsgStub = sinon.stub(element, '_modifyRevertMsg',
             function() { return newRevertMsg; });
@@ -260,6 +405,9 @@
       });
 
       test('works', function() {
+        element.change = {
+          current_revision: 'abc1234',
+        };
         var populateRevertMsgStub = sinon.stub(
             element.$.confirmRevertDialog, 'populateRevertMessage',
             function() { return 'original msg'; });
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 8b51312..83e182a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -16,7 +16,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-label/gr-label.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
@@ -36,18 +36,30 @@
       .title {
         color: #666;
         font-weight: bold;
+        white-space: nowrap;
+      }
+      gr-account-link {
+        max-width: 20ch;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        vertical-align: middle;
+        white-space: nowrap;
+      }
+      gr-editable-label {
+        max-width: 9em;
       }
       .labelValueContainer:not(:first-of-type) {
         margin-top: .25em;
       }
       .labelValueContainer .approved,
       .labelValueContainer .notApproved {
-        display: inline-block;
+        display: inline-flex;
         padding: .1em .3em;
         border-radius: 3px;
       }
       .labelValue {
         display: inline-block;
+        padding-right: .3em;
       }
       .approved {
         background-color: #d4ffd4;
@@ -55,6 +67,9 @@
       .notApproved {
         background-color: #ffd4d4;
       }
+      .labelStatus {
+        max-width: 9em;
+      }
       @media screen and (max-width: 50em), screen and (min-width: 75em) {
         :host {
           display: table;
@@ -128,18 +143,6 @@
       <span class="value">[[change.branch]]</span>
     </section>
     <section>
-      <span class="title">Commit</span>
-      <span class="value">
-        <template is="dom-if" if="[[_showWebLink]]">
-          <a target="_blank"
-             href$="[[_webLink]]">[[_computeShortHash(commitInfo)]]</a>
-        </template>
-        <template is="dom-if" if="[[!_showWebLink]]">
-          [[_computeShortHash(commitInfo)]]
-        </template>
-      </span>
-    </section>
-    <section>
       <span class="title">Topic</span>
       <span class="value">
         <gr-editable-label
@@ -159,7 +162,7 @@
         <span class="title">[[labelName]]</span>
         <span class="value">
           <template is="dom-repeat"
-              items="[[_computeLabelValues(labelName, change.labels)]]"
+              items="[[_computeLabelValues(labelName, change.labels.*)]]"
               as="label">
             <div class="labelValueContainer">
               <span class$="[[label.className]]">
@@ -169,13 +172,27 @@
                     class="labelValue">
                   [[label.value]]
                 </gr-label>
-                <gr-account-link account="[[label.account]]"></gr-account-link>
+                <gr-account-chip
+                    account="[[label.account]]"
+                    data-account-id$="[[label.account._account_id]]"
+                    label-name="[[labelName]]"
+                    removable="[[_computeCanDeleteVote(label.account, mutable)]]"
+                    transparent-background
+                    on-remove="_onDeleteVote"></gr-account-chip>
               </span>
             </div>
           </template>
         </span>
       </section>
     </template>
+    <template is="dom-if" if="[[_showLabelStatus]]">
+      <section>
+        <span class="title">Label Status</span>
+        <span class="value labelStatus">
+          [[_computeSubmitStatus(change.labels)]]
+        </span>
+      </section>
+    </template>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-change-metadata.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index af19703..01ef473 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -27,17 +27,8 @@
 
     properties: {
       change: Object,
-      commitInfo: Object,
       mutable: Boolean,
       serverConfig: Object,
-      _showWebLink: {
-        type: Boolean,
-        computed: '_computeShowWebLink(change, commitInfo, serverConfig)',
-      },
-      _webLink: {
-        type: String,
-        computed: '_computeWebLink(change, commitInfo, serverConfig)',
-      },
       _topicReadOnly: {
         type: Boolean,
         computed: '_computeTopicReadOnly(mutable, change)',
@@ -46,44 +37,16 @@
         type: Boolean,
         computed: '_computeShowReviewersByState(serverConfig)',
       },
+      _showLabelStatus: {
+        type: Boolean,
+        computed: '_computeShowLabelStatus(change)',
+      },
     },
 
     behaviors: [
       Gerrit.RESTClientBehavior,
     ],
 
-    _computeShowWebLink: function(change, commitInfo, serverConfig) {
-      var webLink = commitInfo.web_links && commitInfo.web_links.length;
-      var gitWeb = serverConfig.gitweb && serverConfig.gitweb.url &&
-          serverConfig.gitweb.type && serverConfig.gitweb.type.revision;
-      return webLink || gitWeb;
-    },
-
-    _computeWebLink: function(change, commitInfo, serverConfig) {
-      if (!this._computeShowWebLink(change, commitInfo, serverConfig)) {
-        return;
-      }
-
-      if (serverConfig.gitweb && serverConfig.gitweb.url &&
-          serverConfig.gitweb.type && serverConfig.gitweb.type.revision) {
-        return serverConfig.gitweb.url +
-            serverConfig.gitweb.type.revision
-                .replace('${project}', change.project)
-                .replace('${commit}', commitInfo.commit);
-      }
-
-      var webLink = commitInfo.web_links[0].url;
-      if (!/^https?\:\/\//.test(webLink)) {
-        webLink = '../../' + webLink;
-      }
-
-      return webLink;
-    },
-
-    _computeShortHash: function(commitInfo) {
-      return commitInfo.commit.slice(0, 7);
-    },
-
     _computeHideStrategy: function(change) {
       return !this.changeIsOpen(change.status);
     },
@@ -96,8 +59,9 @@
       return Object.keys(labels).sort();
     },
 
-    _computeLabelValues: function(labelName, labels) {
+    _computeLabelValues: function(labelName, _labels) {
       var result = [];
+      var labels = _labels.base;
       var t = labels[labelName];
       if (!t) { return result; }
       var approvals = t.all || [];
@@ -128,7 +92,7 @@
 
     _handleTopicChanged: function(e, topic) {
       if (!topic.length) { topic = null; }
-      this.$.restAPI.setChangeTopic(this.change.id, topic);
+      this.$.restAPI.setChangeTopic(this.change.change_id, topic);
     },
 
     _computeTopicReadOnly: function(mutable, change) {
@@ -142,5 +106,71 @@
     _computeShowReviewersByState: function(serverConfig) {
       return !!serverConfig.note_db_enabled;
     },
+
+    /**
+     * A user is able to delete a vote iff the mutable property is true and the
+     * reviewer that left the vote exists in the list of removable_reviewers
+     * received from the backend.
+     *
+     * @param {!Object} reviewer An object describing the reviewer that left the
+     *     vote.
+     * @param {boolean} mutable this.mutable describes whether the
+     *     change-metadata section is modifiable by the current user.
+     */
+    _computeCanDeleteVote: function(reviewer, mutable) {
+      if (!mutable) { return false; }
+      for (var i = 0; i < this.change.removable_reviewers.length; i++) {
+        if (this.change.removable_reviewers[i]._account_id ===
+            reviewer._account_id) {
+          return true;
+        }
+      }
+      return false;
+    },
+
+    _onDeleteVote: function(e) {
+      e.preventDefault();
+      var target = Polymer.dom(e).rootTarget;
+      var labelName = target.labelName;
+      var accountID = parseInt(target.getAttribute('data-account-id'), 10);
+      this._xhrPromise =
+          this.$.restAPI.deleteVote(this.change.id, accountID, labelName)
+          .then(function(response) {
+        if (!response.ok) { return response; }
+
+        var labels = this.change.labels[labelName].all || [];
+        for (var i = 0; i < labels.length; i++) {
+          if (labels[i]._account_id === accountID) {
+            this.splice(['change.labels', labelName, 'all'], i, 1);
+            break;
+          }
+        }
+      }.bind(this));
+    },
+
+    _computeShowLabelStatus: function(change) {
+      var isNewChange = change.status === this.ChangeStatus.NEW;
+      var hasLabels = Object.keys(change.labels).length > 0;
+      return isNewChange && hasLabels;
+    },
+
+    _computeSubmitStatus: function(labels) {
+      var missingLabels = [];
+      var output = '';
+      for (var label in labels) {
+        var obj = labels[label];
+        if (!obj.optional && !obj.approved) {
+          missingLabels.push(label);
+        }
+      }
+      if (missingLabels.length) {
+        output += 'Needs ';
+        output += missingLabels.join(' and ');
+        output += missingLabels.length > 1 ? ' labels' : ' label';
+      } else {
+        output = 'Ready to submit';
+      }
+      return output;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 01f0649..7fa4744 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -20,11 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-<script src="../../../bower_components/page/page.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-change-metadata.html">
-<script src="../../../scripts/util.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -38,6 +36,7 @@
 
     setup(function() {
       stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
         getLoggedIn: function() { return Promise.resolve(false); },
       });
 
@@ -68,79 +67,6 @@
       assert.isTrue(element.$$('.strategy').hasAttribute('hidden'));
     });
 
-    test('no web link when unavailable', function() {
-      element.commitInfo = {};
-      element.serverConfig = {};
-      element.change = {labels: []};
-
-      assert.isNotOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-    });
-
-    test('use web link when available', function() {
-      element.commitInfo = {web_links: [{url: 'link-url'}]};
-      element.serverConfig = {};
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), '../../link-url');
-    });
-
-    test('does not relativize web links that begin with scheme', function() {
-      element.commitInfo = {web_links: [{url: 'https://link-url'}]};
-      element.serverConfig = {};
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), 'https://link-url');
-    });
-
-    test('use gitweb when available', function() {
-      element.commitInfo = {commit: 'commit-sha'};
-      element.serverConfig = {gitweb: {
-        url: 'url-base/',
-        type: {revision: 'xx ${project} xx ${commit} xx'},
-      }};
-      element.change = {
-        project: 'project-name',
-        labels: [],
-        current_revision: element.commitInfo.commit
-      };
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), 'url-base/xx project-name xx commit-sha xx');
-    });
-
-    test('prefer gitweb when both are available', function() {
-      element.commitInfo = {
-        commit: 'commit-sha',
-        web_links: [{url: 'link-url'}]
-      };
-      element.serverConfig = {gitweb: {
-        url: 'url-base/',
-        type: {revision: 'xx ${project} xx ${commit} xx'},
-      }};
-      element.change = {
-        project: 'project-name',
-        labels: [],
-        current_revision: element.commitInfo.commit
-      };
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-
-      var link = element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig);
-
-      assert.equal(link, 'url-base/xx project-name xx commit-sha xx');
-      assert.notEqual(link, '../../link-url');
-    });
-
     test('show CC section when NoteDb enabled', function() {
       function hasCc() {
         return element._showReviewersByState;
@@ -152,5 +78,101 @@
       element.serverConfig = {note_db_enabled: true};
       assert.isTrue(hasCc());
     });
+
+    test('computes submit status', function() {
+      var labels = {};
+      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
+      labels = {test: {}};
+      assert.equal(element._computeSubmitStatus(labels), 'Needs test label');
+      labels.test.approved = true;
+      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
+      labels.test.approved = false;
+      labels.test.optional = true;
+      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
+      labels.test.optional = false;
+      labels.test2 = {};
+      assert.equal(element._computeSubmitStatus(labels),
+          'Needs test and test2 labels');
+    });
+
+    suite('remove reviewer votes', function() {
+      var sandbox;
+
+      setup(function() {
+        sandbox = sinon.sandbox.create();
+        sandbox.stub(element, '_computeValueTooltip').returns('');
+        sandbox.stub(element, '_computeTopicReadOnly').returns(true);
+        element.change = {
+          change_id: 'the id',
+          topic: 'the topic',
+          status: 'NEW',
+          submit_type: 'CHERRY_PICK',
+          labels: {
+            test: {
+              all: [{_account_id: 1, name: 'bojack', value: 1}],
+              default_value: 0,
+              values: [],
+            },
+          },
+          removable_reviewers: [],
+        };
+      });
+
+      teardown(function() {
+        sandbox.restore();
+      });
+
+      test('_computeCanDeleteVote hides delete button', function() {
+        flushAsynchronousOperations();
+        var button = element.$$('gr-account-chip').$$('gr-button');
+        assert.isTrue(button.hasAttribute('hidden'));
+        element.mutable = true;
+        assert.isTrue(button.hasAttribute('hidden'));
+      });
+
+      test('_computeCanDeleteVote shows delete button', function() {
+        element.change.removable_reviewers = [
+          {
+            _account_id: 1,
+            name: 'bojack',
+          }
+        ];
+        element.mutable = true;
+        flushAsynchronousOperations();
+        var button = element.$$('gr-account-chip').$$('gr-button');
+        assert.isFalse(button.hasAttribute('hidden'));
+      });
+
+      test('deletes votes', function(done) {
+        sandbox.stub(element.$.restAPI, 'deleteVote')
+            .returns(Promise.resolve({'ok': true}));
+        element.change.removable_reviewers = [
+          {
+            _account_id: 1,
+            name: 'bojack',
+          }
+        ];
+        element.mutable = true;
+        flushAsynchronousOperations();
+        var button = element.$$('gr-account-chip').$$('gr-button');
+        MockInteractions.tap(button);
+        flushAsynchronousOperations();
+        var spliceStub = sinon.stub(element, 'splice',
+            function(path, index, length) {
+          assert.deepEqual(path, ['change.labels', 'test', 'all']);
+          assert.equal(index, 0);
+          assert.equal(length, 1);
+          spliceStub.restore();
+          done();
+        });
+      });
+
+      test('changing topic calls setChangeTopic', function() {
+        var topicStub = sandbox.stub(element.$.restAPI, 'setChangeTopic',
+            function() {});
+        element._handleTopicChanged({}, 'the new topic');
+        assert.isTrue(topicStub.calledWith('the id', 'the new topic'));
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index e3f7fd2..6eeb6df 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -18,6 +18,7 @@
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
@@ -29,6 +30,7 @@
 
 <link rel="import" href="../gr-change-actions/gr-change-actions.html">
 <link rel="import" href="../gr-change-metadata/gr-change-metadata.html">
+<link rel="import" href="../gr-commit-info/gr-commit-info.html">
 <link rel="import" href="../gr-download-dialog/gr-download-dialog.html">
 <link rel="import" href="../gr-file-list/gr-file-list.html">
 <link rel="import" href="../gr-messages-list/gr-messages-list.html">
@@ -45,18 +47,16 @@
         color: #666;
         padding: 1em var(--default-horizontal-margin);
       }
-      .headerContainer {
-        height: 4.1em;
-        margin-bottom: .5em;
-      }
       .header {
         align-items: center;
         background-color: var(--view-background-color);
-        border-bottom: 1px solid #ddd;
         display: flex;
-        padding: 1em var(--default-horizontal-margin);
+        padding: .65em var(--default-horizontal-margin);
         z-index: 99;  /* Less than gr-overlay's backdrop */
       }
+      .header .download {
+        margin-right: 1em;
+      }
       .header.pinned {
         border-bottom-color: transparent;
         box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
@@ -69,70 +69,62 @@
         flex: 1;
         font-size: 1.2em;
         font-weight: bold;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
       }
       gr-change-star {
         margin-right: .25em;
         vertical-align: -.425em;
       }
-      .download,
-      .patchSelectLabel {
-        margin-left: 1em;
-      }
-      .header select {
-        margin-left: .5em;
-      }
-      .header .reply {
-        margin-left: var(--default-horizontal-margin);
-      }
       gr-reply-dialog {
         width: 50em;
       }
       .changeStatus {
-        color: #999;
         text-transform: capitalize;
       }
-      section {
-        margin: 10px 0;
-        padding: 10px var(--default-horizontal-margin);
-      }
       /* Strong specificity here is needed due to
          https://github.com/Polymer/polymer/issues/2531 */
       .container section.changeInfo {
-        border-bottom: 1px solid #ddd;
         display: flex;
-        margin-top: 0;
-        padding-top: 0;
+        padding: 0 var(--default-horizontal-margin);
       }
       .changeInfo-column:not(:last-of-type) {
         margin-right: 1em;
         padding-right: 1em;
       }
       .changeMetadata {
-        border-right: 1px solid #ddd;
-        font-size: .9em;
+        font-size: .95em;
       }
-      gr-change-actions {
-        margin-top: 1em;
+      /* Prevent plugin text from overflowing. */
+      #change_plugins {
+        word-break: break-all;
       }
       .commitMessage {
         font-family: var(--monospace-font-family);
         flex: 0 0 72ch;
         margin-right: 2em;
         margin-bottom: 1em;
-        overflow-x: hidden;
-      }
-      .commitMessage h4 {
-        font-family: var(--font-family);
-        font-weight: bold;
-        margin-bottom: .25em;
       }
       .commitMessage gr-linked-text {
-        --linked-text-white-space: pre;
         overflow: auto;
       }
+      .editCommitMessage {
+        margin-top: 1em;
+      }
+      .commitActions {
+        border-bottom: 1px solid #ddd;
+        display: flex;
+        justify-content: space-between;
+        margin-bottom: .5em;
+        padding-bottom: .5em;
+      }
+      .reply {
+        margin-right: .5em;
+      }
+      .mainChangeInfo {
+        display: flex;
+        flex: 1;
+        flex-direction: column;
+        min-width: 0;
+      }
       .commitAndRelated {
         align-content: flex-start;
         display: flex;
@@ -144,14 +136,30 @@
         font-size: .9em;
         overflow: hidden;
       }
+      .patchInfo {
+        border: 1px solid #ddd;
+        margin: 1em var(--default-horizontal-margin);
+      }
+      .patchInfo--oldPatchSet .patchInfo-header {
+        background-color: #fff9c4;
+      }
+      .patchInfo--oldPatchSet .latestPatchContainer {
+        display: initial;
+      }
+      .patchInfo-header,
       gr-file-list {
-        margin-bottom: 1em;
-        padding: 0 var(--default-horizontal-margin);
+        padding: .5em calc(var(--default-horizontal-margin) / 2);
+      }
+      .patchInfo-header {
+        background-color: #f6f6f6;
+        border-bottom: 1px solid #ebebeb;
+        display: flex;
+        justify-content: space-between;
+      }
+      .latestPatchContainer {
+        display: none;
       }
       @media screen and (max-width: 50em) {
-        .headerContainer {
-          height: 5.15em;
-        }
         .header {
           align-items: flex-start;
           flex-direction: column;
@@ -163,30 +171,17 @@
         .header-title {
           font-size: 1.1em;
         }
-        .header-actions {
-          align-items: center;
-          display: flex;
-          justify-content: space-between;
-          margin-top: .5em;
-        }
         gr-reply-dialog {
           min-width: initial;
           width: 90vw;
         }
-        .download {
+        .downloadContainer {
           display: none;
         }
-        .patchSelectLabel {
-          margin-left: 0;
-          margin-right: .5em;
-        }
-        .header select {
-          margin-left: 0;
-          margin-right: .5em;
-        }
-        .header .reply {
-          margin-left: 0;
-          margin-right: .5em;
+        .reply {
+          display: block;
+          margin-right: 0;
+          margin-bottom: .5em;
         }
         .changeInfo-column:not(:last-of-type) {
           margin-right: 0;
@@ -207,93 +202,121 @@
           margin-top: .25em;
           max-width: none;
         }
+        .commitActions {
+          flex-direction: column;
+        }
         .commitMessage {
           flex: initial;
           margin-right: 0;
         }
       }
     </style>
-    <div class="container loading" hidden$="{{!_loading}}">Loading...</div>
+    <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
     <div class="container" hidden$="{{_loading}}">
-      <div class="headerContainer">
-        <div class="header">
-          <span class="header-title">
-            <gr-change-star change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
-            <a href$="[[_computeChangePermalink(_change._number)]]">[[_change._number]]</a><span>:</span>
-            <span>[[_change.subject]]</span>
-            <span class="changeStatus">[[_computeChangeStatus(_change, _patchRange.patchNum)]]</span>
-          </span>
-          <span class="header-actions">
-            <gr-button hidden
-                class="reply"
-                primary$="[[_computeReplyButtonHighlighted(_diffDrafts.*)]]"
-                hidden$="[[!_loggedIn]]"
-                on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
-            <gr-button class="download" on-tap="_handleDownloadTap">Download</gr-button>
-            <span>
-              <label class="patchSelectLabel" for="patchSetSelect">Patch set</label>
-              <select id="patchSetSelect" on-change="_handlePatchChange">
-                <template is="dom-repeat" items="{{_allPatchSets}}" as="patchNumber">
-                  <option value$="[[patchNumber]]" selected$="[[_computePatchIndexIsSelected(index, _patchRange.patchNum)]]">
-                    <span>[[patchNumber]]</span>
-                    /
-                    <span>[[_computeLatestPatchNum(_allPatchSets)]]</span>
-                  </option>
-                </template>
-              </select>
-            </span>
-          </span>
-        </div>
+      <div class="header">
+        <span class="header-title">
+          <gr-change-star change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
+          <a href$="[[_computeChangePermalink(_change._number)]]">[[_change._number]]</a><!--
+       --><span class="changeStatus">[[_computeChangeStatus(_change, _patchRange.patchNum)]]</span><!--
+       --><span>:</span>
+          <span>[[_change.subject]]</span>
+        </span>
       </div>
       <section class="changeInfo">
         <div class="changeInfo-column changeMetadata">
           <gr-change-metadata
               change="{{_change}}"
-              commit-info="[[_commitInfo]]"
               server-config="[[serverConfig]]"
               mutable="[[_loggedIn]]"
               on-show-reply-dialog="_handleShowReplyDialog">
           </gr-change-metadata>
-          <gr-change-actions id="actions"
-              change="[[_change]]"
-              actions="[[_change.actions]]"
-              change-num="[[_changeNum]]"
-              patch-num="[[_patchRange.patchNum]]"
-              commit-info="[[_commitInfo]]"
-              on-reload-change="_handleReloadChange"></gr-change-actions>
+          <!-- Plugins insert content into following container.
+               Stop-gap until PolyGerrit plugins interface is ready.
+               This will not work with Shadow DOM. -->
+          <div id="change_plugins"></div>
         </div>
-        <div class="changeInfo-column commitAndRelated">
-          <div class="commitMessage">
-            <h4>
-              Commit message
+        <div class="changeInfo-column mainChangeInfo">
+          <div class="commitActions" hidden$="[[!_loggedIn]]">
+            <gr-button
+                class="reply"
+                secondary
+                on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
+            <gr-change-actions id="actions"
+                change="[[_change]]"
+                actions="[[_change.actions]]"
+                change-num="[[_changeNum]]"
+                patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+                commit-message="[[_latestCommitMessage]]"
+                on-reload-change="_handleReloadChange"></gr-change-actions>
+          </div>
+          <div class="commitAndRelated">
+            <div class="commitMessage">
+              <gr-editable-content id="commitMessageEditor"
+                  editing="[[_editingCommitMessage]]"
+                  content="{{_latestCommitMessage}}">
+                <gr-linked-text pre
+                    content="[[_latestCommitMessage]]"
+                    config="[[_projectConfig.commentlinks]]"></gr-linked-text>
+              </gr-editable-content>
               <gr-button link
+                  class="editCommitMessage"
                   on-tap="_handleEditCommitMessage"
                   hidden$="[[_hideEditCommitMessage]]">Edit</gr-button>
-            </h4>
-            <gr-editable-content id="commitMessageEditor"
-                editing="[[_editingCommitMessage]]"
-                content="{{_commitInfo.message}}">
-              <gr-linked-text pre
-                  content="[[_commitInfo.message]]"
-                  config="[[_projectConfig.commentlinks]]"></gr-linked-text>
-            </gr-editable-content>
-          </div>
-          <div class="relatedChanges">
-            <gr-related-changes-list id="relatedChanges"
-                change="[[_change]]"
-                patch-num="[[_patchRange.patchNum]]"></gr-related-changes-list>
+            </div>
+            <div class="relatedChanges">
+              <gr-related-changes-list id="relatedChanges"
+                  change="[[_change]]"
+                  patch-num="[[_computeLatestPatchNum(_allPatchSets)]]">
+              </gr-related-changes-list>
+            </div>
           </div>
         </div>
       </section>
-      <gr-file-list id="fileList"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          patch-range="[[_patchRange]]"
-          comments="[[_comments]]"
-          drafts="[[_diffDrafts]]"
-          revisions="[[_change.revisions]]"
-          projectConfig="[[_projectConfig]]"
-          selected-index="{{viewState.selectedFileIndex}}"></gr-file-list>
+      <section class$="patchInfo [[_computePatchInfoClass(_patchRange.patchNum,
+          _allPatchSets)]]">
+        <div class="patchInfo-header">
+          <div>
+            <label class="patchSelectLabel" for="patchSetSelect">
+              Patch set
+            </label>
+            <select id="patchSetSelect" bind-value="{{_selectedPatchSet}}"
+                is="gr-select" on-change="_handlePatchChange">
+              <template is="dom-repeat" items="[[_allPatchSets]]"
+                  as="patchNumber">
+                <option value$="[[patchNumber]]">
+                  <span>[[patchNumber]]</span>
+                  /
+                  <span>[[_computeLatestPatchNum(_allPatchSets)]]</span>
+                </option>
+              </template>
+            </select>
+            <span class="downloadContainer">
+              /
+              <gr-button link
+                  class="download"
+                  on-tap="_handleDownloadTap">Download</gr-button>
+            </span>
+            <span class="latestPatchContainer">
+              /
+              <a href$="/c/[[_change._number]]">Go to latest patch set</a>
+            </span>
+          </div>
+          <gr-commit-info
+              change="[[_change]]"
+              server-config="[[serverConfig]]"
+              commit-info="[[_commitInfo]]"></gr-commit-info>
+        </div>
+        <gr-file-list id="fileList"
+            change="[[_change]]"
+            change-num="[[_changeNum]]"
+            patch-range="[[_patchRange]]"
+            comments="[[_comments]]"
+            drafts="[[_diffDrafts]]"
+            revisions="[[_change.revisions]]"
+            projectConfig="[[_projectConfig]]"
+            selected-index="{{viewState.selectedFileIndex}}"
+            diff-view-mode="{{viewState.diffMode}}"></gr-file-list>
+      </section>
       <gr-messages-list id="messageList"
           change-num="[[_changeNum]]"
           messages="[[_change.messages]]"
@@ -305,6 +328,7 @@
     </div>
     <gr-overlay id="downloadOverlay" with-backdrop>
       <gr-download-dialog
+          id="downloadDialog"
           change="[[_change]]"
           logged-in="[[_loggedIn]]"
           patch-num="[[_patchRange.patchNum]]"
@@ -312,13 +336,12 @@
           on-close="_handleDownloadDialogClose"></gr-download-dialog>
     </gr-overlay>
     <gr-overlay id="replyOverlay"
+        no-cancel-on-outside-click
         on-iron-overlay-opened="_handleReplyOverlayOpen"
         with-backdrop>
       <gr-reply-dialog id="replyDialog"
           change="[[_change]]"
-          patch-num="[[_patchRange.patchNum]]"
-          revisions="[[_change.revisions]]"
-          labels="[[_change.labels]]"
+          patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
           permitted-labels="[[_change.permitted_labels]]"
           diff-drafts="[[_diffDrafts]]"
           server-config="[[serverConfig]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 14ac4d1..bac25be 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -42,6 +42,7 @@
         notify: true,
         value: function() { return {}; },
       },
+      backPage: String,
       serverConfig: Object,
       keyEventTarget: {
         type: Object,
@@ -66,9 +67,16 @@
       _hideEditCommitMessage: {
         type: Boolean,
         computed: '_computeHideEditCommitMessage(_loggedIn, ' +
-            '_editingCommitMessage, _change.*, _patchRange.patchNum)',
+            '_editingCommitMessage, _change)',
       },
-      _patchRange: Object,
+      _latestCommitMessage: {
+        type: String,
+        value: '',
+      },
+      _patchRange: {
+        type: Object,
+        observer: '_updateSelected',
+      },
       _allPatchSets: {
         type: Array,
         computed: '_computeAllPatchSets(_change)',
@@ -78,14 +86,17 @@
         value: false,
       },
       _loading: Boolean,
-      _headerContainerEl: Object,
-      _headerEl: Object,
       _projectConfig: Object,
       _replyButtonLabel: {
         type: String,
         value: 'Reply',
         computed: '_computeReplyButtonLabel(_diffDrafts.*)',
       },
+      _selectedPatchSet: String,
+      _initialLoadComplete: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     behaviors: [
@@ -98,10 +109,6 @@
       '_paramsAndChangeChanged(params, _change)',
     ],
 
-    ready: function() {
-      this._headerEl = this.$$('.header');
-    },
-
     attached: function() {
       this._getLoggedIn().then(function(loggedIn) {
         this._loggedIn = loggedIn;
@@ -114,34 +121,11 @@
           this._handleCommitMessageSave.bind(this));
       this.addEventListener('editable-content-cancel',
           this._handleCommitMessageCancel.bind(this));
-      this.listen(window, 'scroll', '_handleBodyScroll');
+      this.listen(window, 'scroll', '_handleScroll');
     },
 
     detached: function() {
-      this.unlisten(window, 'scroll', '_handleBodyScroll');
-    },
-
-    _handleBodyScroll: function(e) {
-      var containerEl = this._headerContainerEl ||
-          this.$$('.headerContainer');
-
-      // Calculate where the header is relative to the window.
-      var top = containerEl.offsetTop;
-      for (var offsetParent = containerEl.offsetParent;
-           offsetParent;
-           offsetParent = offsetParent.offsetParent) {
-        top += offsetParent.offsetTop;
-      }
-      // The element may not be displayed yet, in which case do nothing.
-      if (top == 0) { return; }
-
-      this._headerEl.classList.toggle('pinned', window.scrollY >= top);
-    },
-
-    _resetHeaderEl: function() {
-      var el = this._headerEl || this.$$('.header');
-      this._headerEl = el;
-      el.classList.remove('pinned');
+      this.unlisten(window, 'scroll', '_handleScroll');
     },
 
     _handleEditCommitMessage: function(e) {
@@ -157,7 +141,7 @@
         this.$.commitMessageEditor.disabled = false;
         if (!resp.ok) { return; }
 
-        this.set('_commitInfo.message', message);
+        this._latestCommitMessage = message;
         this._editingCommitMessage = false;
         this._reloadWindow();
       }.bind(this)).catch(function(err) {
@@ -182,16 +166,8 @@
           }.bind(this));
     },
 
-    _computeHideEditCommitMessage: function(loggedIn, editing, changeRecord,
-        patchNum) {
-      if (!changeRecord || !loggedIn || editing) { return true; }
-
-      patchNum = parseInt(patchNum, 10);
-      if (isNaN(patchNum)) { return true; }
-
-      var change = changeRecord.base;
-      if (!change.current_revision) { return true; }
-      if (change.revisions[change.current_revision]._number !== patchNum) {
+    _computeHideEditCommitMessage: function(loggedIn, editing, change) {
+      if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED) {
         return true;
       }
 
@@ -267,19 +243,7 @@
     },
 
     _handlePatchChange: function(e) {
-      var patchNum = e.target.value;
-      var currentPatchNum;
-      if (this._change.current_revision) {
-        currentPatchNum =
-            this._change.revisions[this._change.current_revision]._number;
-      } else {
-        currentPatchNum = this._computeLatestPatchNum(this._allPatchSets);
-      }
-      if (patchNum == currentPatchNum) {
-        page.show(this.changePath(this._changeNum));
-        return;
-      }
-      page.show(this.changePath(this._changeNum) + '/' + patchNum);
+      this._changePatchNum(parseInt(e.target.value, 10));
     },
 
     _handleReplyTap: function(e) {
@@ -289,7 +253,11 @@
 
     _handleDownloadTap: function(e) {
       e.preventDefault();
-      this.$.downloadOverlay.open();
+      this.$.downloadOverlay.open().then(function() {
+        this.$.downloadOverlay
+            .setFocusStops(this.$.downloadDialog.getFocusStops());
+        this.$.downloadDialog.focus();
+      }.bind(this));
     },
 
     _handleDownloadDialogClose: function(e) {
@@ -300,7 +268,11 @@
       var msg = e.detail.message.message;
       var quoteStr = msg.split('\n').map(
           function(line) { return '> ' + line; }).join('\n') + '\n\n';
-      this.$.replyDialog.draft += quoteStr;
+
+      if (quoteStr !== this.$.replyDialog.quote) {
+        this.$.replyDialog.draft = quoteStr;
+      }
+      this.$.replyDialog.quote = quoteStr;
       this._openReplyDialog();
     },
 
@@ -329,33 +301,87 @@
       this._openReplyDialog(target);
     },
 
-    _paramsChanged: function(value) {
-      if (value.view !== this.tagName.toLowerCase()) { return; }
+    _handleScroll: function() {
+      this.debounce('scroll', function() {
+        history.replaceState(
+            {
+              scrollTop: document.body.scrollTop,
+              path: location.pathname,
+            },
+            location.pathname);
+      }, 150);
+    },
 
-      this._changeNum = value.changeNum;
-      this._patchRange = {
+    _paramsChanged: function(value) {
+      if (value.view !== this.tagName.toLowerCase()) {
+        this._initialLoadComplete = false;
+        return;
+      }
+
+      var patchChanged = this._patchRange &&
+          (this._patchRange.patchNum !== value.patchNum ||
+          this._patchRange.basePatchNum !== value.basePatchNum);
+
+      if (this._changeNum !== value.changeNum) {
+        this._initialLoadComplete = false;
+      }
+
+      var patchRange = {
         patchNum: value.patchNum,
         basePatchNum: value.basePatchNum || 'PARENT',
       };
 
+      if (this._initialLoadComplete && patchChanged) {
+        if (patchRange.patchNum == null) {
+          patchRange.patchNum = this._computeLatestPatchNum(this._allPatchSets);
+        }
+        this._patchRange = patchRange;
+        this._reloadPatchNumDependentResources().then(function() {
+          this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
+            change: this._change,
+            patchNum: patchRange.patchNum,
+          });
+        }.bind(this));
+        return;
+      }
+
+      this._changeNum = value.changeNum;
+      this._patchRange = patchRange;
+
       this._reload().then(function() {
-        this.$.messageList.topMargin = this._headerEl.offsetHeight;
-        this.$.fileList.topMargin = this._headerEl.offsetHeight;
-
-        // Allow the message list to render before scrolling.
-        this.async(function() {
-          this._maybeScrollToMessage();
-        }.bind(this), 1);
-
-        this._maybeShowReplyDialog();
-
-        this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
-          change: this._change,
-          patchNum: this._patchRange.patchNum,
-        });
+        this._performPostLoadTasks();
       }.bind(this));
     },
 
+    _performPostLoadTasks: function() {
+      // Allow the message list and related changes to render before scrolling.
+      // Related changes are loaded here (after everything else) because they
+      // take the longest and are secondary information. Because the element may
+      // alter the total height of the page, the call to potentially scroll to
+      // a linked message is performed after related changes is fully loaded.
+      this.$.relatedChanges.reload().then(function() {
+        this.async(function() {
+          if (history.state && history.state.scrollTop) {
+            document.documentElement.scrollTop =
+                document.body.scrollTop = history.state.scrollTop;
+          } else {
+            this._maybeScrollToMessage();
+          }
+        }, 1);
+      }.bind(this));
+
+      this._maybeShowReplyDialog();
+
+      this._maybeShowRevertDialog();
+
+      this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
+        change: this._change,
+        patchNum: this._patchRange.patchNum,
+      });
+
+      this._initialLoadComplete = true;
+    },
+
     _paramsAndChangeChanged: function(value) {
       // If the change number or patch range is different, then reset the
       // selected file index.
@@ -375,6 +401,35 @@
       }
     },
 
+    _getLocationSearch: function() {
+      // Not inlining to make it easier to test.
+      return window.location.search;
+    },
+
+    _getUrlParameter: function(param) {
+      var pageURL = this._getLocationSearch().substring(1);
+      var vars = pageURL.split('&');
+      for (var i = 0; i < vars.length; i++) {
+        var name = vars[i].split('=');
+        if (name[0] == param) {
+          return name[0];
+        }
+      }
+      return null;
+    },
+
+    _maybeShowRevertDialog: function() {
+      this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn || this._change.status !== this.ChangeStatus.MERGED) {
+          // Do not display dialog if not logged-in or the change is not merged.
+          return;
+        }
+        if (!!this._getUrlParameter('revert')) {
+          this.$.actions.showRevertDialog();
+        }
+      }.bind(this));
+    },
+
     _maybeShowReplyDialog: function() {
       this._getLoggedIn().then(function(loggedIn) {
         if (!loggedIn) { return; }
@@ -389,6 +444,12 @@
 
     _resetFileListViewState: function() {
       this.set('viewState.selectedFileIndex', 0);
+      if (!!this.viewState.changeNum &&
+          this.viewState.changeNum !== this._changeNum) {
+        // Reset the diff mode to null when navigating from one change to
+        // another, so that the user's preference is restored.
+        this.set('viewState.diffMode', null);
+      }
       this.set('viewState.changeNum', this._changeNum);
       this.set('viewState.patchRange', this._patchRange);
     },
@@ -401,10 +462,31 @@
           this._patchRange.patchNum ||
               this._computeLatestPatchNum(this._allPatchSets));
 
+      this._updateSelected();
+
       var title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
       this.fire('title-change', {title: title});
     },
 
+    /**
+     * Change active patch to the provided patch num.
+     * @param {int} patchNum the patchn number to be viewed.
+     */
+    _changePatchNum: function(patchNum) {
+      var currentPatchNum;
+      if (this._change.current_revision) {
+        currentPatchNum =
+            this._change.revisions[this._change.current_revision]._number;
+      } else {
+        currentPatchNum = this._computeLatestPatchNum(this._allPatchSets);
+      }
+      if (patchNum === currentPatchNum) {
+        page.show(this.changePath(this._changeNum));
+        return;
+      }
+      page.show(this.changePath(this._changeNum) + '/' + patchNum);
+    },
+
     _computeChangePermalink: function(changeNum) {
       return '/' + changeNum;
     },
@@ -419,13 +501,21 @@
       } else {
         statusString = this.changeStatusString(change);
       }
-      return statusString ? '(' + statusString + ')' : '';
+      return statusString ? ' (' + statusString + ')' : '';
     },
 
     _computeLatestPatchNum: function(allPatchSets) {
       return allPatchSets[allPatchSets.length - 1];
     },
 
+    _computePatchInfoClass: function(patchNum, allPatchSets) {
+      if (parseInt(patchNum, 10) ===
+          this._computeLatestPatchNum(allPatchSets)) {
+        return '';
+      }
+      return 'patchInfo--oldPatchSet';
+    },
+
     _computeAllPatchSets: function(change) {
       var patchNums = [];
       for (var rev in change.revisions) {
@@ -444,10 +534,6 @@
       }
     },
 
-    _computePatchIndexIsSelected: function(index, patchNum) {
-      return this._allPatchSets[index] == patchNum;
-    },
-
     _computeLabelNames: function(labels) {
       return Object.keys(labels).sort();
     },
@@ -477,11 +563,6 @@
       return result;
     },
 
-    _computeReplyButtonHighlighted: function(changeRecord) {
-      var drafts = (changeRecord && changeRecord.base) || {};
-      return Object.keys(drafts).length > 0;
-    },
-
     _computeReplyButtonLabel: function(changeRecord) {
       var drafts = (changeRecord && changeRecord.base) || {};
       var draftCount = Object.keys(drafts).reduce(function(count, file) {
@@ -495,9 +576,17 @@
       return label;
     },
 
-    _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+    _switchToMostRecentPatchNum: function() {
+      this._getChangeDetail().then(function() {
+        var patchNum = this._allPatchSets[this._allPatchSets.length - 1];
+        if (patchNum !== this._patchRange.patchNum) {
+          this._changePatchNum(patchNum);
+        }
+      }.bind(this));
+    },
 
+    _handleKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       switch (e.keyCode) {
         case 65:  // 'a'
           if (this._loggedIn && !e.shiftKey) {
@@ -505,15 +594,52 @@
             this._openReplyDialog();
           }
           break;
+        case 68: // 'd'
+          e.preventDefault();
+          this.$.downloadOverlay.open();
+          break;
+        case 82: // 'r'
+          if (e.shiftKey) {
+            e.preventDefault();
+            this._switchToMostRecentPatchNum();
+          }
+          break;
         case 85:  // 'u'
           e.preventDefault();
-          page.show('/');
+          this._determinePageBack();
           break;
       }
     },
 
+    _determinePageBack: function() {
+      // Default backPage to '/' if user came to change view page
+      // via an email link, etc.
+      page.show(this.backPage || '/');
+    },
+
+    _handleLabelRemoved: function(splices, path) {
+      for (var i = 0; i < splices.length; i++) {
+        var splice = splices[i];
+        for (var j = 0; j < splice.removed.length; j++) {
+          var removed = splice.removed[j];
+          var changePath = path.split('.');
+          var labelPath = changePath.splice(0, changePath.length - 2);
+          var labelDict = this.get(labelPath);
+          if (labelDict.approved &&
+              labelDict.approved._account_id === removed._account_id) {
+            this._reload();
+            return;
+          }
+        }
+      }
+    },
+
     _labelsChanged: function(changeRecord) {
       if (!changeRecord) { return; }
+      if (changeRecord.value.indexSplices) {
+        this._handleLabelRemoved(changeRecord.value.indexSplices,
+            changeRecord.path);
+      }
       this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, {
         change: this._change,
       });
@@ -527,7 +653,7 @@
     },
 
     _handleReloadChange: function() {
-      page.show(this.changePath(this._changeNum));
+      this._reload();
     },
 
     _handleGetChangeDetailError: function(response) {
@@ -572,6 +698,14 @@
           }.bind(this));
     },
 
+    _getLatestCommitMessage: function() {
+      return this.$.restAPI.getChangeCommitInfo(this._changeNum,
+          this._computeLatestPatchNum(this._allPatchSets)).then(
+              function(commitInfo) {
+                this._latestCommitMessage = commitInfo.message;
+              }.bind(this));
+    },
+
     _getCommitInfo: function() {
       return this.$.restAPI.getChangeCommitInfo(
           this._changeNum, this._patchRange.patchNum).then(
@@ -603,33 +737,50 @@
       }.bind(this));
       this._getComments();
 
-      var reloadPatchNumDependentResources = function() {
-        return Promise.all([
-          this._getCommitInfo(),
-          this.$.actions.reload(),
-          this.$.fileList.reload(),
-        ]);
-      }.bind(this);
-      var reloadDetailDependentResources = function() {
-        if (!this._change) { return Promise.resolve(); }
-
-        return Promise.all([
-          this.$.relatedChanges.reload(),
-          this._getProjectConfig(),
-        ]);
-      }.bind(this);
-
-      this._resetHeaderEl();
-
       if (this._patchRange.patchNum) {
-        return reloadPatchNumDependentResources().then(function() {
+        return this._reloadPatchNumDependentResources().then(function() {
           return detailCompletes;
-        }).then(reloadDetailDependentResources);
+        }).then(function() {
+          return this._reloadDetailDependentResources();
+        }.bind(this));
       } else {
         // The patch number is reliant on the change detail request.
-        return detailCompletes.then(reloadPatchNumDependentResources).then(
-            reloadDetailDependentResources);
+        return detailCompletes.then(function() {
+          return this._reloadPatchNumDependentResources();
+        }.bind(this)).then(function() {
+          return this._reloadDetailDependentResources();
+        }.bind(this));
       }
     },
+
+    /**
+     * Kicks off requests for resources that rely on the change detail
+     * (`this._change`) being loaded.
+     */
+    _reloadDetailDependentResources: function() {
+      if (!this._change) { return Promise.resolve(); }
+
+      return this._getProjectConfig().then(function() {
+        return Promise.all([
+          this._getLatestCommitMessage(),
+          this.$.actions.reload(),
+        ]);
+      }.bind(this));
+    },
+
+    /**
+     * Kicks off requests for resources that rely on the patch range
+     * (`this._patchRange`) being defined.
+     */
+    _reloadPatchNumDependentResources: function() {
+      return Promise.all([
+        this._getCommitInfo(),
+        this.$.fileList.reload(),
+      ]);
+    },
+
+    _updateSelected: function() {
+      this._selectedPatchSet = this._patchRange.patchNum;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index c9a687b..e61d6b0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -21,7 +21,6 @@
 <script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../../../bower_components/page/page.js"></script>
-<script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-change-view.html">
@@ -35,52 +34,154 @@
 <script>
   suite('gr-change-view tests', function() {
     var element;
+    var sandbox;
+    var TEST_SCROLL_TOP_PX = 100;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
         getAccount: function() { return Promise.resolve(null); },
       });
       element = fixture('basic');
     });
 
-    test('keyboard shortcuts', function() {
-      var showStub = sinon.stub(page, 'show');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'U'
-      assert(showStub.lastCall.calledWithExactly('/'),
-          'Should navigate to /');
-      showStub.restore();
-
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'A'
-      var overlayEl = element.$.replyOverlay;
-      assert.isFalse(overlayEl.opened);
-      element._loggedIn = true;
-
-      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'A'
-      assert.isFalse(overlayEl.opened);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'A'
-      assert.isTrue(overlayEl.opened);
-      overlayEl.close();
-      assert.isFalse(overlayEl.opened);
+    teardown(function() {
+      sandbox.restore();
     });
 
-    test('reply button is highlighted when there are drafts', function() {
+    suite('keyboard shortcuts', function() {
+      test('U should navigate to / if no backPage set', function() {
+        var showStub = sandbox.stub(page, 'show');
+        MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'U'
+        assert(showStub.lastCall.calledWithExactly('/'));
+      });
+
+      test('U should navigate to backPage if set', function() {
+        element.backPage = '/dashboard/self';
+        var showStub = sandbox.stub(page, 'show');
+        MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'U'
+        assert(showStub.lastCall.calledWithExactly('/dashboard/self'));
+      });
+
+      test('A should toggle overlay', function() {
+        MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'A'
+        var overlayEl = element.$.replyOverlay;
+        assert.isFalse(overlayEl.opened);
+        element._loggedIn = true;
+
+        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'A'
+        assert.isFalse(overlayEl.opened);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'A'
+        assert.isTrue(overlayEl.opened);
+        overlayEl.close();
+        assert.isFalse(overlayEl.opened);
+      });
+
+      test('shift + R should fetch and navigate to the latest patch set',
+          function(done) {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: 'PARENT',
+          patchNum: 1,
+        };
+        element._change = {
+          change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+          revisions: {
+            rev1: {_number: 1},
+          },
+          current_revision: 'rev1',
+          status: 'NEW',
+          labels: {},
+          actions: {},
+        };
+
+        sandbox.stub(element.$.restAPI, '_getChangeDetail', function() {
+          // Mock change obj.
+          return Promise.resolve({
+            change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+            revisions: {
+              rev1: {_number: 1},
+              rev13: {_number: 13},
+            },
+            current_revision: 'rev1',
+            status: 'NEW',
+            labels: {},
+            actions: {},
+          });
+        });
+
+        var showStub = sandbox.stub(page, 'show', function(arg) {
+          assert.equal(arg, '/c/42/13');
+          done();
+        });
+
+        // 'shift + R'
+        MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift');
+      });
+
+      test('d should open download overlay', function() {
+        var stub = sandbox.stub(element.$.downloadOverlay, 'open');
+        MockInteractions.pressAndReleaseKeyOn(element, 68); // 'd'
+        assert.isTrue(stub.called);
+      });
+    });
+
+    test('_reload is called when an approved label is removed', function() {
+      var vote = {_account_id: 1, name: 'bojack', value: 1};
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2},
+          rev1: {_number: 1},
+          rev13: {_number: 13},
+          rev3: {_number: 3},
+        },
+        current_revision: 'rev3',
+        status: 'NEW',
+        labels: {
+          test: {
+            all: [vote],
+            default_value: 0,
+            values: [],
+            approved: {},
+          },
+        },
+      };
+      flushAsynchronousOperations();
+      var reloadStub = sandbox.stub(element, '_reload');
+      element.splice('_change.labels.test.all', 0, 1);
+      assert.isFalse(reloadStub.called);
+      element._change.labels.test.all.push(vote);
+      element._change.labels.test.all.push(vote);
+      element._change.labels.test.approved = vote;
+      flushAsynchronousOperations();
+      element.splice('_change.labels.test.all', 0, 2);
+      assert.isTrue(reloadStub.called);
+      assert.isTrue(reloadStub.calledOnce);
+    });
+
+    test('reply button has updated count when there are drafts', function() {
       var replyButton = element.$$('gr-button.reply');
       assert.ok(replyButton);
-      assert.isFalse(replyButton.hasAttribute('primary'));
+      assert.equal(replyButton.textContent, 'Reply');
 
       element._diffDrafts = null;
-      assert.isFalse(replyButton.hasAttribute('primary'));
+      assert.equal(replyButton.textContent, 'Reply');
 
       element._diffDrafts = {};
-      assert.isFalse(replyButton.hasAttribute('primary'));
+      assert.equal(replyButton.textContent, 'Reply');
 
       element._diffDrafts = {
         'file1.txt': [{}],
         'file2.txt': [{}, {}],
       };
-      assert.isTrue(replyButton.hasAttribute('primary'));
       assert.equal(replyButton.textContent, 'Reply (3)');
     });
 
@@ -120,6 +221,36 @@
       assert.deepEqual(element._diffDrafts, {});
     });
 
+    test('change num change', function() {
+      element._changeNum = null;
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        labels: {},
+      };
+      element.viewState.changeNum = null;
+      element.viewState.diffMode = 'UNIFIED';
+      flushAsynchronousOperations();
+      assert.equal(element.viewState.diffMode, 'UNIFIED');
+
+      element._changeNum = '1';
+      element.params = {changeNum: '1'};
+      element._change.newProp = '1';
+      flushAsynchronousOperations();
+      assert.equal(element.viewState.diffMode, 'UNIFIED');
+      assert.equal(element.viewState.changeNum, '1');
+
+      element._changeNum = '2';
+      element.params = {changeNum: '2'};
+      element._change.newProp = '2';
+      flushAsynchronousOperations();
+      assert.isNull(element.viewState.diffMode);
+      assert.equal(element.viewState.changeNum, '2');
+    });
+
     test('patch num change', function(done) {
       element._changeNum = '42';
       element._patchRange = {
@@ -138,24 +269,25 @@
         status: 'NEW',
         labels: {},
       };
+      element.viewState.diffMode = 'UNIFIED';
       flushAsynchronousOperations();
-      var selectEl = element.$$('.header select');
+
+      var selectEl = element.$$('.patchInfo-header select');
       assert.ok(selectEl);
-      var optionEls =
-          Polymer.dom(element.root).querySelectorAll('.header option');
+      var optionEls = Polymer.dom(element.root).querySelectorAll(
+          '.patchInfo-header option');
       assert.equal(optionEls.length, 4);
-      assert.isFalse(
-          element.$$('.header option[value="1"]').hasAttribute('selected'));
-      assert.isTrue(
-          element.$$('.header option[value="2"]').hasAttribute('selected'));
-      assert.isFalse(
-          element.$$('.header option[value="3"]').hasAttribute('selected'));
+      var select = element.$$('.patchInfo-header #patchSetSelect').bindValue;
+      assert.notEqual(select, 1);
+      assert.equal(select, 2);
+      assert.notEqual(select, 3);
       assert.equal(optionEls[3].value, 13);
 
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
 
       var numEvents = 0;
       selectEl.addEventListener('change', function(e) {
+        assert.equal(element.viewState.diffMode, 'UNIFIED');
         numEvents++;
         if (numEvents == 1) {
           assert(showStub.lastCall.calledWithExactly('/c/42/1'),
@@ -165,7 +297,6 @@
         } else if (numEvents == 2) {
           assert(showStub.lastCall.calledWithExactly('/c/42'),
               'Should navigate to /c/42');
-          showStub.restore();
           done();
         }
       });
@@ -191,20 +322,20 @@
         labels: {},
       };
       flushAsynchronousOperations();
-      var selectEl = element.$$('.header select');
+      var selectEl = element.$$('.patchInfo-header select');
       assert.ok(selectEl);
-      var optionEls =
-          Polymer.dom(element.root).querySelectorAll('.header option');
+      var optionEls = Polymer.dom(element.root).querySelectorAll(
+          '.patchInfo-header option');
       assert.equal(optionEls.length, 4);
-      assert.isFalse(
-          element.$$('.header option[value="1"]').hasAttribute('selected'));
-      assert.isTrue(
-          element.$$('.header option[value="2"]').hasAttribute('selected'));
-      assert.isFalse(
-          element.$$('.header option[value="3"]').hasAttribute('selected'));
+      assert.notEqual(
+        element.$$('.patchInfo-header #patchSetSelect').bindValue, 1);
+      assert.equal(
+        element.$$('.patchInfo-header #patchSetSelect').bindValue, 2);
+      assert.notEqual(
+        element.$$('.patchInfo-header #patchSetSelect').bindValue, 3);
       assert.equal(optionEls[3].value, 13);
 
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
 
       var numEvents = 0;
       selectEl.addEventListener('change', function(e) {
@@ -217,7 +348,6 @@
         } else if (numEvents == 2) {
           assert(showStub.lastCall.calledWithExactly('/c/42/3'),
               'Should navigate to /c/42/3');
-          showStub.restore();
           done();
         }
       });
@@ -225,6 +355,29 @@
       element.fire('change', {}, {node: selectEl});
     });
 
+    test('don’t reload entire page when patchRange changes', function() {
+      var reloadStub = sandbox.stub(element, '_reload',
+          function() { return Promise.resolve(); });
+      var reloadPatchDependentStub = sandbox.stub(element,
+          '_reloadPatchNumDependentResources',
+          function() { return Promise.resolve(); });
+
+      var value = {
+        view: 'gr-change-view',
+        patchNum: '1',
+      };
+      element._paramsChanged(value);
+      assert.isTrue(reloadStub.calledOnce);
+      element._initialLoadComplete = true;
+
+      value.basePatchNum = '1';
+      value.patchNum = '2';
+      element._paramsChanged(value);
+      assert.isFalse(reloadStub.calledTwice);
+      assert.isTrue(reloadPatchDependentStub.calledOnce);
+
+    });
+
     test('change status new', function() {
       element._changeNum = '1';
       element._patchRange = {
@@ -260,7 +413,7 @@
         labels: {},
       };
       var status = element._computeChangeStatus(element._change, '1');
-      assert.equal(status, '(Draft)');
+      assert.equal(status, ' (Draft)');
     });
 
     test('revision status draft', function() {
@@ -283,43 +436,36 @@
         labels: {},
       };
       var status = element._computeChangeStatus(element._change, '2');
-      assert.equal(status, '(Draft)');
+      assert.equal(status, ' (Draft)');
     });
 
     test('show commit message edit button', function() {
-      var changeRecord = {
-        base: {
-          revisions: {
-            rev1: {_number: 1},
-            rev2: {_number: 2},
-          },
-          current_revision: 'rev2',
-        },
+      var _change = {
+        status: element.ChangeStatus.MERGED,
       };
-      assert.isTrue(element._computeHideEditCommitMessage(
-          false, false, changeRecord, '2'));
-      assert.isTrue(element._computeHideEditCommitMessage(
-          true, true, changeRecord, '2'));
-      assert.isTrue(element._computeHideEditCommitMessage(
-          true, false, changeRecord, '1'));
-      assert.isFalse(element._computeHideEditCommitMessage(
-          true, false, changeRecord, '2'));
+      assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
+      assert.isTrue(element._computeHideEditCommitMessage(true, true, {}));
+      assert.isTrue(element._computeHideEditCommitMessage(false, true, {}));
+      assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
+      assert.isTrue(element._computeHideEditCommitMessage(true, false,
+          _change));
     });
 
-    test('topic is coalesced to null', function() {
-      sinon.stub(element, '_changeChanged');
-      sinon.stub(element.$.restAPI, 'getChangeDetail', function(num) {
+    test('topic is coalesced to null', function(done) {
+      sandbox.stub(element, '_changeChanged');
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', function() {
         return Promise.resolve({id: '123456789', labels: {}});
       });
 
       element._getChangeDetail().then(function() {
         assert.isNull(element._change.topic);
+        done();
       });
     });
 
     test('reply dialog focus can be controlled', function() {
       var FocusTarget = element.$.replyDialog.FocusTarget;
-      var openSpy = sinon.spy(element, '_openReplyDialog');
+      var openSpy = sandbox.spy(element, '_openReplyDialog');
 
       var e = {detail: {}};
       element._handleShowReplyDialog(e);
@@ -331,5 +477,145 @@
       assert(openSpy.lastCall.calledWithExactly(FocusTarget.CCS),
           '_openReplyDialog should have been passed CCS');
     });
+
+    test('class is applied to file list on old patch set', function() {
+      var allPatcheSets = [1, 2, 4];
+      assert.equal(element._computePatchInfoClass('1', allPatcheSets),
+          'patchInfo--oldPatchSet');
+      assert.equal(element._computePatchInfoClass('2', allPatcheSets),
+          'patchInfo--oldPatchSet');
+      assert.equal(element._computePatchInfoClass('4', allPatcheSets), '');
+    });
+
+    test('getUrlParameter functionality', function() {
+      var locationStub = sandbox.stub(element, '_getLocationSearch');
+
+      locationStub.returns('?test');
+      assert.equal(element._getUrlParameter('test'), 'test');
+      locationStub.returns('?test2=12&test=3');
+      assert.equal(element._getUrlParameter('test'), 'test');
+      locationStub.returns('');
+      assert.isNull(element._getUrlParameter('test'));
+      locationStub.returns('?');
+      assert.isNull(element._getUrlParameter('test'));
+      locationStub.returns('?test2');
+      assert.isNull(element._getUrlParameter('test'));
+
+    });
+
+    test('revert dialog opened with revert param', function(done) {
+      sandbox.stub(element.$.restAPI, 'getLoggedIn', function() {
+        return Promise.resolve(true);
+      });
+
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: element.ChangeStatus.MERGED,
+        labels: {},
+        actions: {},
+      };
+
+      var urlParamStub = sandbox.stub(element, '_getUrlParameter',
+          function(param) {
+            assert.equal(param, 'revert');
+            return param;
+          });
+
+      var revertDialogStub = sandbox.stub(element.$.actions, 'showRevertDialog',
+          done);
+
+      element._maybeShowRevertDialog();
+    });
+
+    suite('scroll related tests', function() {
+      test('document scrolling calls function to set scroll height',
+          function(done) {
+            var originalHeight = document.body.scrollHeight;
+            var scrollStub = sandbox.stub(element, '_handleScroll',
+                function() {
+                  assert.isTrue(scrollStub.called);
+                  document.body.style.height =
+                      originalHeight + 'px';
+                  scrollStub.restore();
+                  done();
+                });
+            document.body.style.height = '10000px';
+            document.body.scrollTop = TEST_SCROLL_TOP_PX;
+            element._handleScroll();
+          });
+
+      test('history is loaded correctly', function() {
+        history.replaceState(
+            {
+              scrollTop: 100,
+              path: location.pathname,
+            },
+            location.pathname);
+
+        var reloadStub = sandbox.stub(element, '_reload',
+            function() {
+              // When element is reloaded, ensure that the history
+              // state has the scrollTop set earlier. This will then
+              // be reset.
+              assert.isTrue(history.state.scrollTop == 100);
+              return Promise.resolve({});
+            });
+
+        // simulate reloading component, which is done when route
+        // changes to match a regex of change view type.
+        element._paramsChanged({view: 'gr-change-view'});
+      });
+    });
+
+    suite('reply dialog tests', function() {
+      setup(function() {
+        sandbox.stub(element.$.replyDialog, '_draftChanged');
+      });
+
+      test('reply from comment adds quote text', function() {
+        var e = {detail: {message: {message: 'quote text'}}};
+        element._handleMessageReply(e);
+        assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      });
+
+      test('reply from comment replaces quote text', function() {
+        element.$.replyDialog.draft = '> old quote text\n\n some draft text';
+        element.$.replyDialog.quote = '> old quote text\n\n';
+        var e = {detail: {message: {message: 'quote text'}}};
+        element._handleMessageReply(e);
+        assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      });
+
+      test('reply from same comment preserves quote text', function() {
+        element.$.replyDialog.draft = '> quote text\n\n some draft text';
+        element.$.replyDialog.quote = '> quote text\n\n';
+        var e = {detail: {message: {message: 'quote text'}}};
+        element._handleMessageReply(e);
+        assert.equal(element.$.replyDialog.draft,
+            '> quote text\n\n some draft text');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      });
+
+      test('reply from top of page contains previous draft', function() {
+        var div = document.createElement('div');
+        element.$.replyDialog.draft = '> quote text\n\n some draft text';
+        element.$.replyDialog.quote = '> quote text\n\n';
+        var e = {target: div, preventDefault: sandbox.spy()};
+        element._handleReplyTap(e);
+        assert.equal(element.$.replyDialog.draft,
+            '> quote text\n\n some draft text');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
index a7d99a7..8102006 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -14,7 +14,9 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 
 <dom-module id="gr-comment-list">
   <template>
@@ -22,6 +24,7 @@
       :host {
         display: block;
         font-family: var(--monospace-font-family);
+        word-wrap: break-word;
       }
       .file {
         border-top: 1px solid #ddd;
@@ -39,8 +42,7 @@
       }
       .message {
         flex: 1;
-        white-space: pre-wrap;
-        word-wrap: break-word;
+        max-width: 80ch;
       }
     </style>
     <template is="dom-repeat" items="[[_computeFilesFromComments(comments)]]" as="file">
@@ -60,7 +62,10 @@
                File comment:
              </span>
           </a>
-          <div class="message">[[comment.message]]</div>
+          <gr-linked-text class="message"
+              pre
+              content="[[comment.message]]"
+              config="[[projectConfig.commentlinks]]"></gr-linked-text>
         </div>
       </template>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
index eaafc447..1adfb01 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -16,15 +16,18 @@
 
   Polymer({
     is: 'gr-comment-list',
+    behaviors: [Gerrit.PathListBehavior],
 
     properties: {
       changeNum: Number,
       comments: Object,
       patchNum: Number,
+      projectConfig: Object,
     },
 
     _computeFilesFromComments: function(comments) {
-      return Object.keys(comments || {}).sort();
+      var arr = Object.keys(comments || {});
+      return arr.sort(this.specialFilePathCompare);
     },
 
     _computeFileDiffURL: function(file, changeNum, patchNum) {
@@ -55,6 +58,6 @@
         return 'PS' + comment.patch_set + ', ';
       }
       return '';
-    }
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index 56a927b..a132d43 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -36,9 +36,21 @@
       element = fixture('basic');
     });
 
-    test('_computeFilesFromComments', function() {
-      var comments = {'file_b.html': [], 'file_c.css': [], 'file_a.js': []};
-      var expected = ['file_a.js', 'file_b.html', 'file_c.css'];
+    test('_computeFilesFromComments w/ special file path sorting', function() {
+      var comments = {
+        'file_b.html': [],
+        'file_c.css': [],
+        'file_a.js': [],
+        'test.cc': [],
+        'test.h': [],
+      };
+      var expected = [
+        'file_a.js',
+        'file_b.html',
+        'file_c.css',
+        'test.h',
+        'test.cc'
+      ];
       var actual = element._computeFilesFromComments(comments);
       assert.deepEqual(actual, expected);
 
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
new file mode 100644
index 0000000..5cd65fa
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
@@ -0,0 +1,35 @@
+<!--
+Copyright (C) 2016 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-commit-info">
+  <template>
+    <style>
+      :host {
+        display: inline-block;
+      }
+    </style>
+    <template is="dom-if" if="[[_showWebLink]]">
+      <a target="_blank"
+         href$="[[_webLink]]">[[_computeShortHash(commitInfo)]]</a>
+    </template>
+    <template is="dom-if" if="[[!_showWebLink]]">
+      [[_computeShortHash(commitInfo)]]
+    </template>
+  </template>
+  <script src="gr-commit-info.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
new file mode 100644
index 0000000..5aa8601
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
@@ -0,0 +1,98 @@
+// Copyright (C) 2016 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-commit-info',
+
+    properties: {
+      change: Object,
+      commitInfo: Object,
+      serverConfig: Object,
+      _showWebLink: {
+        type: Boolean,
+        computed: '_computeShowWebLink(change, commitInfo, serverConfig)',
+      },
+      _webLink: {
+        type: String,
+        computed: '_computeWebLink(change, commitInfo, serverConfig)',
+      },
+    },
+
+    _isWebLink: function(link) {
+      // This is a whitelist of web link types that provide direct links to
+      // the commit in the url property.
+      return link.name === 'gitiles' || link.name === 'gitweb';
+    },
+
+    _computeShowWebLink: function(change, commitInfo, serverConfig) {
+      if (serverConfig.gitweb && serverConfig.gitweb.url &&
+          serverConfig.gitweb.type && serverConfig.gitweb.type.revision) {
+        return true;
+      }
+
+      if (!commitInfo.web_links) {
+        return false;
+      }
+
+      for (var i = 0; i < commitInfo.web_links.length; i++) {
+        if (this._isWebLink(commitInfo.web_links[i])) {
+          return true;
+        }
+      }
+
+      return false;
+    },
+
+    _computeWebLink: function(change, commitInfo, serverConfig) {
+      if (!this._computeShowWebLink(change, commitInfo, serverConfig)) {
+        return;
+      }
+
+      if (serverConfig.gitweb && serverConfig.gitweb.url &&
+          serverConfig.gitweb.type && serverConfig.gitweb.type.revision) {
+        return serverConfig.gitweb.url +
+            serverConfig.gitweb.type.revision
+                .replace('${project}', change.project)
+                .replace('${commit}', commitInfo.commit);
+      }
+
+      var webLink = null;
+      for (var i = 0; i < commitInfo.web_links.length; i++) {
+        if (this._isWebLink(commitInfo.web_links[i])) {
+          webLink = commitInfo.web_links[i].url;
+          break;
+        }
+      }
+
+      if (!webLink) {
+        return;
+      }
+
+      if (!/^https?\:\/\//.test(webLink)) {
+        webLink = '../../' + webLink;
+      }
+
+      return webLink;
+    },
+
+    _computeShortHash: function(commitInfo) {
+      if (!commitInfo || !commitInfo.commit) {
+        return;
+      }
+      return commitInfo.commit.slice(0, 7);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
new file mode 100644
index 0000000..36b1628
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
@@ -0,0 +1,145 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-commit-info</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-commit-info.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-commit-info></gr-commit-info>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-commit-info tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('no web link when unavailable', function() {
+      element.commitInfo = {};
+      element.serverConfig = {};
+      element.change = {labels: []};
+
+      assert.isNotOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+    });
+
+    test('use web link when available', function() {
+      element.commitInfo = {web_links: [{name: 'gitweb', url: 'link-url'}]};
+      element.serverConfig = {};
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+      assert.equal(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig), '../../link-url');
+    });
+
+    test('does not relativize web links that begin with scheme', function() {
+      element.commitInfo = {
+        web_links: [{name: 'gitweb', url: 'https://link-url'}]
+      };
+      element.serverConfig = {};
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+      assert.equal(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig), 'https://link-url');
+    });
+
+    test('use gitweb when available', function() {
+      element.commitInfo = {commit: 'commit-sha'};
+      element.serverConfig = {gitweb: {
+        url: 'url-base/',
+        type: {revision: 'xx ${project} xx ${commit} xx'},
+      }};
+      element.change = {
+        project: 'project-name',
+        labels: [],
+        current_revision: element.commitInfo.commit
+      };
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+
+      assert.equal(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig), 'url-base/xx project-name xx commit-sha xx');
+    });
+
+    test('prefer gitweb when both are available', function() {
+      element.commitInfo = {
+        commit: 'commit-sha',
+        web_links: [{url: 'link-url'}]
+      };
+      element.serverConfig = {gitweb: {
+        url: 'url-base/',
+        type: {revision: 'xx ${project} xx ${commit} xx'},
+      }};
+      element.change = {
+        project: 'project-name',
+        labels: [],
+        current_revision: element.commitInfo.commit
+      };
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+
+      var link = element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig);
+
+      assert.equal(link, 'url-base/xx project-name xx commit-sha xx');
+      assert.notEqual(link, '../../link-url');
+    });
+
+    test('ignore web links that are neither gitweb nor gitiles', function() {
+      element.commitInfo = {
+        commit: 'commit-sha',
+        web_links: [
+          {
+            name: 'ignore',
+            url: 'ignore',
+          },
+          {
+            name: 'gitiles',
+            url: 'https://link-url',
+          }
+        ],
+      };
+      element.serverConfig = {};
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+      assert.equal(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig), 'https://link-url');
+
+      // Remove gitiles link.
+      element.commitInfo.web_links.splice(1, 1);
+      assert.isNotOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+      assert.isNotOk(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
index 7366d74..ea65c01 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
@@ -28,6 +28,11 @@
         opacity: .5;
         pointer-events: none;
       }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
       label {
         cursor: pointer;
         display: block;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
index 0ce1cbb..e47f14f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
@@ -33,6 +33,10 @@
       message: String,
     },
 
+    resetFocus: function() {
+      this.$.messageInput.textarea.focus();
+    },
+
     _handleConfirmTap: function(e) {
       e.preventDefault();
       this.fire('confirm', {reason: this.message}, {bubbles: false});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
index b21575b..ae738e5 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
@@ -35,6 +35,11 @@
       iron-autogrow-textarea {
         padding: 0;
       }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
       .main label,
       .main input[type="text"] {
         display: block;
@@ -66,6 +71,8 @@
         <iron-autogrow-textarea
             id="messageInput"
             class="message"
+            rows="4"
+            max-rows="15"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
     </gr-confirm-dialog>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
index 97342d1..f27e4e2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -32,16 +32,6 @@
     properties: {
       branch: String,
       message: String,
-      commitInfo: {
-        type: Object,
-        readOnly: true,
-        observer: '_commitInfoChanged',
-      },
-    },
-
-    _commitInfoChanged: function(commitInfo) {
-      // Pre-populate cherry-pick message for editing from commit info.
-      this.message = commitInfo.message;
     },
 
     _handleConfirmTap: function(e) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
index 979a06a..b668d06 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
@@ -55,6 +55,7 @@
         </label>
         <iron-autogrow-textarea
             id="messageInput"
+            max-rows="15"
             class="message"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
index b4baa26..8f621f0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
@@ -30,28 +30,22 @@
      */
 
     properties: {
-      branch: String,
       message: String,
-      commitInfo: Object,
     },
 
-    populateRevertMessage: function() {
+    populateRevertMessage: function(message, commitHash) {
       // Figure out what the revert title should be.
-      var originalTitle = this.commitInfo.message.split('\n')[0];
-      var revertTitle = 'Revert of ' + originalTitle;
-      if (originalTitle.startsWith('Revert of ')) {
-        revertTitle = 'Reland of ' +
-                      originalTitle.substring('Revert of '.length);
-      } else if (originalTitle.startsWith('Reland of ')) {
-        revertTitle = 'Revert of ' +
-                      originalTitle.substring('Reland of '.length);
+      var originalTitle = message.split('\n')[0];
+      var revertTitle = 'Revert "' + originalTitle + '"';
+      if (!commitHash) {
+        alert('Unable to find the commit hash of this change.');
+        return;
       }
-      // Add '> ' in front of the original commit text.
-      var originalCommitText = this.commitInfo.message.replace(/^/gm, '> ');
+      var revertCommitText = 'This reverts commit ' + commitHash + '.';
 
       this.message = revertTitle + '\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
-                     'Original issue\'s description:\n' + originalCommitText;
+                     revertCommitText + '\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
     },
 
     _handleConfirmTap: function(e) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
index 1d53eef..f5672d3 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
@@ -38,27 +38,55 @@
       element = fixture('basic');
     });
 
+    test('no match', function() {
+      assert.isNotOk(element.message);
+      var alertStub = sinon.stub(window, 'alert');
+      element.populateRevertMessage('not a commitHash in sight', undefined);
+      assert.isTrue(alertStub.calledOnce);
+      alertStub.restore();
+    });
+
     test('single line', function() {
       assert.isNotOk(element.message);
-      element.commitInfo = {message: 'one line commit'};
-      assert.isNotOk(element.message);
-      element.populateRevertMessage();
-      var expected = 'Revert of one line commit\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
-                     'Original issue\'s description:\n' +
-                     '> one line commit';
+      element.populateRevertMessage(
+          'one line commit\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      var expected = 'Revert "one line commit"\n\n' +
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
 
     test('multi line', function() {
       assert.isNotOk(element.message);
-      element.commitInfo = {message: 'many lines\ncommit\n\nmessage\n'};
+      element.populateRevertMessage(
+          'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      var expected = 'Revert "many lines"\n\n' +
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+
+    test('issue above change id', function() {
       assert.isNotOk(element.message);
-      element.populateRevertMessage();
-      var expected = 'Revert of many lines\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
-                     'Original issue\'s description:\n' +
-                     '> many lines\n> commit\n> \n> message\n> ';
+      element.populateRevertMessage(
+          'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+          'abcd123');
+      var expected = 'Revert "much lines"\n\n' +
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+
+    test('revert a revert', function() {
+      assert.isNotOk(element.message);
+      element.populateRevertMessage(
+          'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      var expected = 'Revert "Revert "one line commit""\n\n' +
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index b1e5c01..7c888da 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -100,7 +100,9 @@
         </template>
       </ul>
       <span class="closeButtonContainer">
-        <gr-button link on-tap="_handleCloseTap">Close</gr-button>
+        <gr-button id="closeButton"
+            link
+            on-tap="_handleCloseTap">Close</gr-button>
       </span>
     </header>
     <main hidden$="[[!_schemes.length]]" hidden>
@@ -121,7 +123,7 @@
       <div class="patchFiles">
         <label>Patch file</label>
         <div>
-          <a href$="[[_computeDownloadLink(change, patchNum)]]">
+          <a id="download" href$="[[_computeDownloadLink(change, patchNum)]]">
             [[_computeDownloadFilename(change, patchNum)]]
           </a>
           <a href$="[[_computeZipDownloadLink(change, patchNum)]]">
@@ -131,7 +133,7 @@
       </div>
       <div class="archivesContainer" hidden$="[[!config.archives.length]]" hidden>
         <label>Archive</label>
-        <div class="archives">
+        <div id="archives" class="archives">
           <template is="dom-repeat" items="[[config.archives]]" as="format">
             <a href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]">
               [[format]]
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index 2f3e8e1..b331899 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -58,6 +58,18 @@
       }.bind(this));
     },
 
+    focus: function() {
+      this.$.download.focus();
+    },
+
+    getFocusStops: function() {
+      var links = this.$$('#archives').querySelectorAll('a');
+      return {
+        start: this.$.closeButton,
+        end: links[links.length - 1],
+      };
+    },
+
     _computeDownloadCommands: function(change, patchNum, _selectedScheme) {
       var commandObj;
       for (var rev in change.revisions) {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index 70e934d..ad5086b 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -115,6 +115,14 @@
       };
     });
 
+    test('focuses on first download link', function() {
+      var focusStub = sinon.stub(element.$.download, 'focus');
+      element.focus();
+      flushAsynchronousOperations();
+      assert.isTrue(focusStub.called);
+      focusStub.restore();
+    });
+
     test('element visibility', function() {
       assert.isFalse(element.$$('ul').hasAttribute('hidden'));
       assert.isFalse(element.$$('main').hasAttribute('hidden'));
@@ -154,7 +162,6 @@
         assert.isFalse(el.hasAttribute('selected'));
       });
     });
-
   });
 
   suite('gr-download-dialog tests', function() {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index ef5ceed..f11cca2 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -14,12 +14,15 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../diff/gr-diff/gr-diff.html">
 <link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
 
 <dom-module id="gr-file-list">
   <template>
@@ -38,7 +41,13 @@
         margin-bottom: .5em;
       }
       .rightControls {
+        display: flex;
+        flex-wrap: wrap;
         font-weight: normal;
+        justify-content: flex-end;
+      }
+      .separator {
+        margin: 0 .25em;
       }
       .reviewed,
       .status {
@@ -87,7 +96,8 @@
       .invisible {
         visibility: hidden;
       }
-      .row:not(.header) .stats {
+      .row:not(.header) .stats,
+      .total-stats {
         font-family: var(--monospace-font-family);
       }
       .added {
@@ -100,6 +110,31 @@
         color: #C62828;
         font-weight: bold;
       }
+      .show-hide {
+        margin-left: .4em;
+      }
+      .fileListButton {
+        margin: .5em;
+      }
+      .totalChanges {
+        justify-content: flex-end;
+        padding-right: 2.6em;
+        text-align: right;
+      }
+      .warning {
+        color: #666;
+      }
+      input.show-hide {
+        display: none;
+      }
+      label.show-hide {
+        color: #00f;
+        cursor: pointer;
+        display: block;
+        font-size: .8em;
+        min-width: 2em;
+        margin-top: .1em;
+      }
       gr-diff {
         box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
         display: block;
@@ -124,34 +159,61 @@
     <header>
       <div>Files</div>
       <div class="rightControls">
-        <gr-button link on-tap="_expandAllDiffs">Show diffs</gr-button>
-        /
-        <gr-button link on-tap="_collapseAllDiffs">Hide diffs</gr-button>
-        /
+        <template is="dom-if"
+            if="[[_fileListActionsVisible(_numFilesShown, _maxFilesForBulkActions)]]">
+          <gr-button link on-tap="_expandAllDiffs">Show diffs</gr-button>
+          <span class="separator">/</span>
+          <gr-button link on-tap="_collapseAllDiffs">Hide diffs</gr-button>
+        </template>
+        <template is="dom-if"
+            if="[[!_fileListActionsVisible(_numFilesShown, _maxFilesForBulkActions)]]">
+          <div class="warning">
+            Bulk file list actions disabled for large amounts of files
+          </div>
+        </template>
+        <span class="separator">/</span>
+        <select
+            id="modeSelect"
+            is="gr-select"
+            bind-value="{{diffViewMode}}"
+            on-change="_handleDropdownChange">
+          <option value="SIDE_BY_SIDE">Side By Side</option>
+          <option value="UNIFIED_DIFF">Unified</option>
+        </select>
+        <span class="separator">/</span>
         <label>
           Diff against
-          <select on-change="_handlePatchChange">
+          <select id="patchChange" bind-value="{{_diffAgainst}}" is="gr-select"
+              on-change="_handlePatchChange">
             <option value="PARENT">Base</option>
-            <template is="dom-repeat" items="[[_computePatchSets(revisions, patchRange.*)]]" as="patchNum">
-              <option
-                  value$="[[patchNum]]"
-                  selected$="[[_computePatchSetSelected(patchNum, patchRange.basePatchNum)]]"
-                  disabled$="[[_computePatchSetDisabled(patchNum, patchRange.patchNum)]]">[[patchNum]]</option>
+            <template 
+                is="dom-repeat" 
+                items="[[_computePatchSets(revisions, patchRange.*)]]"
+                as="patchNum">
+              <option value$="[[patchNum]]" disabled$=
+                  "[[_computePatchSetDisabled(patchNum, patchRange.patchNum)]]">
+                [[patchNum]]
+              </option>
             </template>
           </select>
         </label>
       </div>
     </header>
-    <template is="dom-repeat" items="[[_files]]" as="file">
+    <template is="dom-repeat"
+        items="[[_shownFiles]]"
+        as="file"
+        initial-count="[[_fileListIncrement]]">
       <div class="row" selected$="[[_computeFileSelected(index, selectedIndex)]]">
         <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
           <input type="checkbox" checked$="[[_computeReviewed(file, _reviewed)]]"
-              data-path$="[[file.__path]]" on-change="_handleReviewedChange">
+              data-path$="[[file.__path]]" on-change="_handleReviewedChange"
+              class="reviewed">
         </div>
         <div class$="[[_computeClass('status', file.__path)]]">
           [[_computeFileStatus(file.status)]]
         </div>
-        <a class="path" href$="[[_computeDiffURL(changeNum, patchRange, file.__path)]]">
+        <a class="path"
+            href$="[[_computeDiffURL(changeNum, patchRange, file.__path)]]">
           <div title$="[[_computeFileDisplayName(file.__path)]]">
             [[_computeFileDisplayName(file.__path)]]
           </div>
@@ -161,15 +223,27 @@
           </div>
         </a>
         <div class="comments">
-          <span class="drafts">[[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]</span>
+          <span class="drafts">
+            [[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]
+          </span>
           [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
         </div>
         <div class$="[[_computeClass('stats', file.__path)]]">
           <span class="added">+[[file.lines_inserted]]</span>
           <span class="removed">-[[file.lines_deleted]]</span>
         </div>
+        <div class="show-hide">
+          <label class="show-hide">
+            <input type="checkbox" class="show-hide"
+                checked$="[[!file.__expanded]]" data-path$="[[file.__path]]"
+                on-change="_handleHiddenChange">
+            [[_computeShowHideText(file.__expanded)]]
+          </label>
+        </div>
       </div>
-      <gr-diff hidden
+      <gr-diff
+          hidden$="[[!file.__expanded]]"
+          expanded="[[file.__expanded]]"
           project="[[change.project]]"
           commit="[[change.current_revision]]"
           change-num="[[changeNum]]"
@@ -177,13 +251,31 @@
           path="[[file.__path]]"
           prefs="[[_diffPrefs]]"
           project-config="[[projectConfig]]"
-          view-mode="[[_userPrefs.diff_view]]"></gr-diff>
+          view-mode="[[_diffMode]]"></gr-diff>
     </template>
+    <div class="row totalChanges">
+      <div class="total-stats" hidden$="[[_hideChangeTotals]]">
+        <span class="added">+[[_patchChange.inserted]]</span>
+        <span class="removed">-[[_patchChange.deleted]]</span>
+      </div>
+    </div>
+    <gr-button
+        class="fileListButton"
+        id="incrementButton"
+        hidden$="[[_computeFileListButtonHidden(_numFilesShown, _files)]]"
+        link on-tap="_incrementNumFilesShown">
+      [[_computeIncrementText(_numFilesShown, _files)]]
+    </gr-button>
+    <gr-button
+        class="fileListButton"
+        id="showAllButton"
+        hidden$="[[_computeFileListButtonHidden(_numFilesShown, _files)]]"
+        link on-tap="_showAllFiles">
+      [[_computeShowAllText(_files)]]
+    </gr-button>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
-    <gr-diff-cursor
-        id="cursor"
-        fold-offset-top="[[topMargin]]"></gr-diff-cursor>
+    <gr-diff-cursor id="cursor"></gr-diff-cursor>
   </template>
   <script src="gr-file-list.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 225d8b3..bf6dcf3 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -16,18 +16,25 @@
 
   var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
 
+  var DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
   Polymer({
     is: 'gr-file-list',
 
     properties: {
-      patchRange: Object,
+      patchRange: {
+        type: Object,
+        observer: '_updateSelected',
+      },
       patchNum: String,
       changeNum: String,
       comments: Object,
       drafts: Object,
       revisions: Object,
       projectConfig: Object,
-      topMargin: Number,
       selectedIndex: {
         type: Number,
         notify: true,
@@ -37,10 +44,14 @@
         value: function() { return document.body; },
       },
       change: Object,
-
+      diffViewMode: {
+        type: String,
+        notify: true,
+      },
       _files: {
         type: Array,
         observer: '_filesChanged',
+        value: function() { return []; },
       },
       _loggedIn: {
         type: Boolean,
@@ -50,23 +61,55 @@
         type: Array,
         value: function() { return []; },
       },
+      _diffAgainst: String,
       _diffPrefs: Object,
       _userPrefs: Object,
       _localPrefs: Object,
       _showInlineDiffs: Boolean,
+      _numFilesShown: {
+        type: Number,
+        value: 75,
+      },
+      _patchChange: {
+        type: Object,
+        computed: '_calculatePatchChange(_files)',
+      },
+      _fileListIncrement: {
+        type: Number,
+        readOnly: true,
+        value: 75,
+      },
+      _hideChangeTotals: {
+        type: Boolean,
+        computed: '_shouldHideChangeTotals(_patchChange)',
+      },
+      _shownFiles: {
+        type: Array,
+        computed: '_computeFilesShown(_numFilesShown, _files.*)',
+      },
+      _diffMode: {
+        type: String,
+        computed: '_getDiffViewMode(diffViewMode, _userPrefs)',
+      },
+      // Caps the number of files that can be shown and have the 'show diffs' /
+      // 'hide diffs' buttons still be functional.
+      _maxFilesForBulkActions: {
+        type: Number,
+        readOnly: true,
+        value: 225,
+      },
     },
 
     behaviors: [
       Gerrit.KeyboardShortcutBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     reload: function() {
       if (!this.changeNum || !this.patchRange.patchNum) {
         return Promise.resolve();
       }
-
       this._collapseAllDiffs();
-
       var promises = [];
       var _this = this;
 
@@ -88,8 +131,17 @@
         this._diffPrefs = prefs;
       }.bind(this)));
 
+      // Initialize with user's diff mode preference. Default to
+      // SIDE_BY_SIDE in the meantime.
+      var setDiffViewMode = this.diffViewMode === null;
+      if (setDiffViewMode) {
+        this.set('diffViewMode', DiffViewMode.SIDE_BY_SIDE);
+      }
       promises.push(this._getPreferences().then(function(prefs) {
         this._userPrefs = prefs;
+        if (setDiffViewMode) {
+          this.set('diffViewMode', prefs.diff_view);
+        }
       }.bind(this)));
     },
 
@@ -97,6 +149,22 @@
       return Polymer.dom(this.root).querySelectorAll('gr-diff');
     },
 
+    _calculatePatchChange: function(files) {
+      var filesNoCommitMsg = files.filter(function(files) {
+        return files.__path !== '/COMMIT_MSG';
+      });
+
+      return filesNoCommitMsg.reduce(function(acc, obj) {
+        var inserted = obj.lines_inserted ? obj.lines_inserted : 0;
+        var deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+
+        return {
+          inserted: acc.inserted + inserted,
+          deleted: acc.deleted + deleted,
+        };
+      }, {inserted: 0, deleted: 0});
+    },
+
     _getDiffPreferences: function() {
       return this.$.restAPI.getDiffPreferences();
     },
@@ -117,14 +185,16 @@
       return parseInt(patchNum, 10) >= parseInt(currentPatchNum, 10);
     },
 
-    _computePatchSetSelected: function(patchNum, basePatchNum) {
-      return parseInt(patchNum, 10) === parseInt(basePatchNum, 10);
+    _handleHiddenChange: function(e) {
+      var model = e.model;
+      model.set('file.__expanded', !model.file.__expanded);
     },
 
     _handlePatchChange: function(e) {
-      this.set('patchRange.basePatchNum', Polymer.dom(e).rootTarget.value);
-      page.show('/c/' + encodeURIComponent(this.changeNum) + '/' +
-          encodeURIComponent(this._patchRangeStr(this.patchRange)));
+      var patchRange = Object.assign({}, this.patchRange);
+      patchRange.basePatchNum = Polymer.dom(e).rootTarget.value;
+      page.show(this.encodeURL('/c/' + this.changeNum + '/' +
+          this._patchRangeStr(patchRange), true));
     },
 
     _forEachDiff: function(fn) {
@@ -134,12 +204,17 @@
       }
     },
 
+    /**
+     * Until upgrading to Polymer 2.0, manual management of reflection between
+     * _shownFiles and _files is necessary. Performance of linkPaths is very
+     * poor.
+     */
     _expandAllDiffs: function(e) {
       this._showInlineDiffs = true;
-      this._forEachDiff(function(diff) {
-        diff.hidden = false;
-        diff.reload();
-      });
+      for (var i = 0; i < this._shownFiles.length; i++) {
+        this.set(['_shownFiles', i, '__expanded'], true);
+        this.set(['_files', i, '__expanded'], true);
+      }
       if (e && e.target) {
         e.target.blur();
       }
@@ -147,9 +222,10 @@
 
     _collapseAllDiffs: function(e) {
       this._showInlineDiffs = false;
-      this._forEachDiff(function(diff) {
-        diff.hidden = true;
-      });
+      for (var i = 0; i < this._shownFiles.length; i++) {
+        this.set(['_shownFiles', i, '__expanded'], false);
+        this.set(['_files', i, '__expanded'], false);
+      }
       this.$.cursor.handleDiffUpdate();
       if (e && e.target) {
         e.target.blur();
@@ -212,12 +288,17 @@
 
     _getFiles: function() {
       return this.$.restAPI.getChangeFilesAsSpeciallySortedArray(
-          this.changeNum, this.patchRange);
+          this.changeNum, this.patchRange).then(function(files) {
+            // Append UI-specific properties.
+            return files.map(function(file) {
+              file.__expanded = false;
+              return file;
+            });
+          });
     },
 
     _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
-
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       switch (e.keyCode) {
         case 37: // left
           if (e.shiftKey && this._showInlineDiffs) {
@@ -232,9 +313,18 @@
           }
           break;
         case 73:  // 'i'
-          if (!e.shiftKey) { return; }
-          e.preventDefault();
-          this._toggleInlineDiffs();
+          if (e.shiftKey) {
+            e.preventDefault();
+            this._toggleInlineDiffs();
+          } else if (this.selectedIndex !== undefined) {
+            e.preventDefault();
+            var expanded = this._files[this.selectedIndex].__expanded;
+            // Until Polymer 2.0, manual management of reflection between _files
+            // and _shownFiles is necessary.
+            this.set(['_shownFiles', this.selectedIndex, '__expanded'],
+                !expanded);
+            this.set(['_files', this.selectedIndex, '__expanded'], !expanded);
+          }
           break;
         case 40:  // down
         case 74:  // 'j'
@@ -243,7 +333,7 @@
             this.$.cursor.moveDown();
           } else {
             this.selectedIndex =
-                Math.min(this._files.length - 1, this.selectedIndex + 1);
+                Math.min(this._numFilesShown, this.selectedIndex + 1);
             this._scrollToSelectedFile();
           }
           break;
@@ -352,7 +442,7 @@
       }
 
       // Don't scroll if it's already in view.
-      if (top > window.pageYOffset + this.topMargin &&
+      if (top > window.pageYOffset &&
           top < window.pageYOffset + window.innerHeight - el.clientHeight) {
         return;
       }
@@ -360,6 +450,10 @@
       window.scrollTo(0, top - document.body.clientHeight / 2);
     },
 
+    _shouldHideChangeTotals: function(_patchChange) {
+      return (_patchChange.inserted === 0 && _patchChange.deleted === 0);
+    },
+
     _computeFileSelected: function(index, selectedIndex) {
       return index === selectedIndex;
     },
@@ -369,12 +463,8 @@
     },
 
     _computeDiffURL: function(changeNum, patchRange, path) {
-      return '/c/' +
-          encodeURIComponent(changeNum) +
-          '/' +
-          encodeURIComponent(this._patchRangeStr(patchRange)) +
-          '/' +
-          path;
+      return this.encodeURL('/c/' + changeNum + '/' +
+          this._patchRangeStr(patchRange) + '/' + path, true);
     },
 
     _patchRangeStr: function(patchRange) {
@@ -395,6 +485,14 @@
       return classes.join(' ');
     },
 
+    _computeShowHideText: function(expanded) {
+      return expanded ? 'â–¼' : 'â—€';
+    },
+
+    _computeFilesShown: function(numFilesShown, files) {
+      return files.base.slice(0, numFilesShown);
+    },
+
     _filesChanged: function() {
       this.async(function() {
         var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
@@ -404,5 +502,64 @@
             ['diffs', 0, this.$.cursor.diffs.length].concat(diffElements));
       }.bind(this), 1);
     },
+
+    _incrementNumFilesShown: function() {
+      this._numFilesShown += this._fileListIncrement;
+    },
+
+    _computeFileListButtonHidden: function(numFilesShown, files) {
+      return numFilesShown >= files.length;
+    },
+
+    _computeIncrementText: function(numFilesShown, files) {
+      if (!files) { return ''; }
+      var text =
+          Math.min(this._fileListIncrement, files.length - numFilesShown);
+      return 'Show ' + text + ' more';
+    },
+
+    _computeShowAllText: function(files) {
+      if (!files) { return ''; }
+      return 'Show all ' + files.length + ' files';
+    },
+
+    _showAllFiles: function() {
+      this._numFilesShown = this._files.length;
+    },
+
+    _updateSelected: function(patchRange) {
+      this._diffAgainst = patchRange.basePatchNum;
+    },
+
+    /**
+     * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
+     * the current state.
+     *
+     * The expected behavior is to use the mode specified in the user's
+     * preferences unless they have manually chosen the alternative view. If the
+     * user navigates up to the change view, it should clear this choice and
+     * revert to the preference the next time a diff is viewed.
+     *
+     * Use side-by-side if the user is not logged in.
+     *
+     * @return {String}
+     */
+    _getDiffViewMode: function() {
+      if (this.diffViewMode) {
+        return this.diffViewMode;
+      } else if (this._userPrefs && this._userPrefs.diff_view) {
+        return this.diffViewMode = this._userPrefs.diff_view;
+      }
+
+      return DiffViewMode.SIDE_BY_SIDE;
+    },
+
+    _handleDropdownChange: function(e) {
+      e.target.blur();
+    },
+
+    _fileListActionsVisible: function(numFilesShown, maxFilesForBulkActions) {
+      return numFilesShown <= maxFilesForBulkActions;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index f61566a..b530bae 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -32,19 +32,39 @@
   </template>
 </test-fixture>
 
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
 <script>
   suite('gr-file-list tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getLoggedIn: function() { return Promise.resolve(true); },
+        getPreferences: function() { return Promise.resolve({}); },
+        fetchJSON: function() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat: function() { return Promise.resolve(''); }
+      });
+      stub('gr-diff', {
+        reload: function() { return Promise.resolve(); },
       });
       element = fixture('basic');
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('get file list', function(done) {
-      var getChangeFilesStub = sinon.stub(element.$.restAPI, 'getChangeFiles',
+      var getChangeFilesStub = sandbox.stub(element.$.restAPI, 'getChangeFiles',
           function() {
             return Promise.resolve({
               '/COMMIT_MSG': {lines_inserted: 9},
@@ -60,16 +80,19 @@
           lines_inserted: 9,
           lines_deleted: 0,
           __path: '/COMMIT_MSG',
+          __expanded: false,
         });
         assert.deepEqual(files[1], {
           lines_inserted: 0,
           lines_deleted: 0,
           __path: 'about.txt',
+          __expanded: false,
         });
         assert.deepEqual(files[2], {
           lines_inserted: 0,
           lines_deleted: 123,
           __path: 'tags.html',
+          __expanded: false,
         });
 
         getChangeFilesStub.restore();
@@ -77,62 +100,122 @@
       });
     });
 
-    test('toggle left diff via shortcut', function() {
-      var toggleLeftDiffStub = sinon.stub();
-      sinon.stub(element, 'diffs', {get: function() {
-        return [{toggleLeftDiff: toggleLeftDiffStub}];
-      }});
-      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'A'
-      assert.isTrue(toggleLeftDiffStub.calledOnce);
+    test('calculate totals for patch number', function() {
+      element._files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
+        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
+      ];
+      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+
+      // Test with a commit message that isn't the first file.
+      element._files = [
+        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
+      ];
+      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+
+      // Test with no commit message.
+      element._files = [
+        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
+        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
+      ];
+      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+
+      // Test with files missing either lines_inserted or lines_deleted.
+      element._files = [
+        {__path: 'file_added_in_rev2.txt', lines_inserted: 1},
+        {__path: 'myfile.txt', lines_deleted: 1},
+      ];
+      assert.deepEqual(element._patchChange, {inserted: 1, deleted: 1});
     });
 
-    test('keyboard shortcuts', function() {
-      var toggleInlineDiffsStub = sinon.stub(element, '_toggleInlineDiffs');
-      MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift');  // 'I'
-      assert.isTrue(toggleInlineDiffsStub.calledOnce);
-      toggleInlineDiffsStub.restore();
+    suite('keyboard shortcuts', function() {
+      setup(function() {
+        element._files = [
+          {__path: '/COMMIT_MSG', __expanded: false},
+          {__path: 'file_added_in_rev2.txt', __expanded: false},
+          {__path: 'myfile.txt', __expanded: false},
+        ];
+        element.changeNum = '42';
+        element.patchRange = {
+          basePatchNum: 'PARENT',
+          patchNum: '2',
+        };
+        element.selectedIndex = 0;
+      });
 
-      element._files = [
-        {__path: '/COMMIT_MSG'},
-        {__path: 'file_added_in_rev2.txt'},
-        {__path: 'myfile.txt'},
-      ];
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      element.selectedIndex = 0;
+      test('toggle left diff via shortcut', function() {
+        var toggleLeftDiffStub = sandbox.stub();
+        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
+        // https://github.com/sinonjs/sinon/issues/781
+        var diffsStub = sinon.stub(element, 'diffs', {
+          get: function() {
+            return [{toggleLeftDiff: toggleLeftDiffStub}];
+          },
+        });
+        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'A'
+        assert.isTrue(toggleLeftDiffStub.calledOnce);
+        diffsStub.restore();
+      });
 
-      flushAsynchronousOperations();
-      var elementItems = Polymer.dom(element.root).querySelectorAll(
-          '.row:not(.header)');
-      assert.equal(elementItems.length, 3);
-      assert.isTrue(elementItems[0].hasAttribute('selected'));
-      assert.isFalse(elementItems[1].hasAttribute('selected'));
-      assert.isFalse(elementItems[2].hasAttribute('selected'));
-      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'J'
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'J'
+      test('keyboard shortcuts', function() {
+        flushAsynchronousOperations();
+        var elementItems = Polymer.dom(element.root).querySelectorAll(
+            '.row:not(.header)');
+        assert.equal(elementItems.length, 4);
+        assert.isTrue(elementItems[0].hasAttribute('selected'));
+        assert.isFalse(elementItems[1].hasAttribute('selected'));
+        assert.isFalse(elementItems[2].hasAttribute('selected'));
+        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'J'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'J'
 
-      var showStub = sinon.stub(page, 'show');
-      assert.equal(element.selectedIndex, 2);
-      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'ENTER'
-      assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
-          'Should navigate to /c/42/2/myfile.txt');
+        var showStub = sandbox.stub(page, 'show');
+        assert.equal(element.selectedIndex, 2);
+        MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'ENTER'
+        assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
+            'Should navigate to /c/42/2/myfile.txt');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 79);  // 'O'
-      assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'),
-          'Should navigate to /c/42/2/file_added_in_rev2.txt');
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 79);  // 'O'
+        assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'),
+            'Should navigate to /c/42/2/file_added_in_rev2.txt');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-      assert.equal(element.selectedIndex, 0);
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
+        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
+        assert.equal(element.selectedIndex, 0);
 
-      showStub.restore();
+        showStub.restore();
+      });
+
+      test('i key shows/hides selected inline diff', function() {
+        element.selectedIndex = 0;
+        MockInteractions.pressAndReleaseKeyOn(element, 73);  // 'I'
+        flushAsynchronousOperations();
+        assert.isFalse(element.diffs[0].hasAttribute('hidden'));
+        MockInteractions.pressAndReleaseKeyOn(element, 73);  // 'I'
+        flushAsynchronousOperations();
+        assert.isTrue(element.diffs[0].hasAttribute('hidden'));
+        element.selectedIndex = 1;
+        MockInteractions.pressAndReleaseKeyOn(element, 73);  // 'I'
+        flushAsynchronousOperations();
+        assert.isFalse(element.diffs[1].hasAttribute('hidden'));
+
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift');  // 'I'
+        flushAsynchronousOperations();
+        for (var index in element.diffs) {
+          assert.isFalse(element.diffs[index].hasAttribute('hidden'));
+        }
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift');  // 'I'
+        flushAsynchronousOperations();
+        for (var index in element.diffs) {
+          assert.isTrue(element.diffs[index].hasAttribute('hidden'));
+        }
+      });
     });
 
     test('comment filtering', function() {
@@ -187,9 +270,9 @@
 
     test('file review status', function() {
       element._files = [
-        {__path: '/COMMIT_MSG'},
-        {__path: 'file_added_in_rev2.txt'},
-        {__path: 'myfile.txt'},
+        {__path: '/COMMIT_MSG', __expanded: false},
+        {__path: 'file_added_in_rev2.txt', __expanded: false},
+        {__path: 'myfile.txt', __expanded: false},
       ];
       element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
       element.changeNum = '42';
@@ -202,15 +285,18 @@
       flushAsynchronousOperations();
       var fileRows =
           Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
-      var commitMsg = fileRows[0].querySelector('input[type="checkbox"]');
-      var fileAdded = fileRows[1].querySelector('input[type="checkbox"]');
-      var myFile = fileRows[2].querySelector('input[type="checkbox"]');
+      var commitMsg = fileRows[0].querySelector(
+          'input.reviewed[type="checkbox"]');
+      var fileAdded = fileRows[1].querySelector(
+          'input.reviewed[type="checkbox"]');
+      var myFile = fileRows[2].querySelector(
+          'input.reviewed[type="checkbox"]');
 
       assert.isTrue(commitMsg.checked);
       assert.isFalse(fileAdded.checked);
       assert.isTrue(myFile.checked);
 
-      var saveStub = sinon.stub(element, '_saveReviewedState',
+      var saveStub = sandbox.stub(element, '_saveReviewedState',
           function() { return Promise.resolve(); });
 
       MockInteractions.tap(commitMsg);
@@ -241,7 +327,7 @@
     });
 
     test('diff against dropdown', function(done) {
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
@@ -253,7 +339,7 @@
         rev3: {_number: 3},
       };
       flush(function() {
-        var selectEl = element.$$('select');
+        var selectEl = element.$.patchChange;
         assert.equal(selectEl.value, 'PARENT');
         assert.isTrue(element.$$('option[value="3"]').hasAttribute('disabled'));
         selectEl.addEventListener('change', function() {
@@ -267,5 +353,110 @@
         element.fire('change', {}, {node: selectEl});
       });
     });
+
+    test('checkbox shows/hides diff inline', function() {
+      element._files = [
+        {__path: 'myfile.txt', __expanded: false},
+      ];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.selectedIndex = 0;
+      flushAsynchronousOperations();
+      var fileRows =
+          Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
+      var showHideCheck = fileRows[0].querySelector(
+          'input.show-hide[type="checkbox"]');
+      assert.isTrue(showHideCheck.checked);
+      MockInteractions.tap(showHideCheck);
+      assert.isFalse(element.diffs[0].hidden);
+    });
+
+    test('path should be properly escaped', function() {
+      element._files = [
+        {__path: 'foo bar/my+file.txt%'},
+      ];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      flushAsynchronousOperations();
+      // Slashes should be preserved, and spaces should be translated to `+`.
+      // @see Issue 4255 regarding double-encoding.
+      // @see Issue 4577 regarding more readable URLs.
+      assert.equal(
+          element.$$('a').getAttribute('href'),
+          '/c/42/2/foo+bar/my%252Bfile.txt%2525');
+    });
+
+    test('diff mode correctly toggles the diffs', function() {
+      element._files = [
+        {__path: 'myfile.txt', __expanded: false},
+      ];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.selectedIndex = 0;
+      flushAsynchronousOperations();
+      var diffDisplay = element.diffs[0];
+      element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+      assert.equal(element.diffViewMode, 'SIDE_BY_SIDE');
+      assert.equal(diffDisplay.viewMode, 'SIDE_BY_SIDE');
+      element.set('diffViewMode', 'UNIFIED_DIFF');
+      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+      assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
+    });
+
+    test('diff mode selector initializes from preferences', function() {
+      var resolvePrefs;
+      var prefsPromise = new Promise(function(resolve) {
+        resolvePrefs = resolve;
+      });
+      sandbox.stub(element, '_getPreferences').returns(prefsPromise);
+
+      // Attach a new gr-file-list so we can intercept the preferences fetch.
+      var view = document.createElement('gr-file-list');
+      var select = view.$.modeSelect;
+      fixture('blank').appendChild(view);
+      flushAsynchronousOperations();
+
+      // At this point the diff mode doesn't yet have the user's preference.
+      assert.equal(select.value, 'SIDE_BY_SIDE');
+
+      // Receive the overriding preference.
+      resolvePrefs({diff_view: 'UNIFIED'});
+      flushAsynchronousOperations();
+      assert.equal(select.value, 'SIDE_BY_SIDE');
+      document.getElementById('blank').restore();
+    });
+
+    test('show/hide diffs disabled for large amounts of files', function(done) {
+      element._files = [];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      var computeSpy = sandbox.spy(element, '_fileListActionsVisible');
+      element.selectedIndex = 0;
+      element._numFilesShown = 1;
+      flush(function() {
+        assert.isTrue(computeSpy.lastCall.returnValue);
+        var arr = [];
+        _.times(element._maxFilesForBulkActions + 1, function() {
+          arr.push({__path: 'myfile.txt', __expanded: false});
+        });
+        element._files = arr;
+        element._numFilesShown = arr.length;
+        assert.isFalse(computeSpy.lastCall.returnValue);
+        done();
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 66254d0..3649655 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -50,12 +50,19 @@
       }
       .showAvatar.collapsed .contentContainer {
         margin-left: calc(var(--default-horizontal-margin) + 1.75em);
-        padding: .75em 2em .75em 0;
+      }
+      .showAvatar.collapsed .contentContainer,
+      .hideAvatar.collapsed .contentContainer {
+        margin-right: calc(var(--default-horizontal-margin) + 2.25em);
       }
       .hideAvatar.collapsed .contentContainer,
       .hideAvatar.expanded .contentContainer {
         margin-left: 0;
-        padding: .75em 2em .75em 0;
+      }
+      .showAvatar.collapsed .contentContainer,
+      .hideAvatar.collapsed .contentContainer,
+      .hideAvatar.expanded .contentContainer {
+        padding: .75em 0;
       }
       .collapsed gr-avatar {
         top: .5em;
@@ -73,6 +80,9 @@
       .content {
         font-family: var(--monospace-font-family);
       }
+      .message {
+        max-width: 80ch;
+      }
       .collapsed .name,
       .collapsed .content,
       .collapsed .message,
@@ -114,7 +124,8 @@
             <gr-comment-list
                 comments="[[comments]]"
                 change-num="[[changeNum]]"
-                patch-num="[[message._revision_number]]"></gr-comment-list>
+                patch-num="[[message._revision_number]]"
+                project-config="[[projectConfig]]"></gr-comment-list>
           </div>
           <a class="date" href$="[[_computeMessageHash(message)]]" on-tap="_handleLinkTap">
             <gr-date-formatter date-str="[[message.date]]"></gr-date-formatter>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index c92ad07..5c903a85 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -50,21 +50,41 @@
         value: true,
         reflectToAttribute: true,
       },
+      hideAutomated: {
+        type: Boolean,
+        value: false,
+      },
+      hidden: {
+        type: Boolean,
+        computed: '_computeIsHidden(hideAutomated, isAutomated)',
+        reflectToAttribute: true,
+      },
+      isAutomated: {
+        type: Boolean,
+        computed: '_computeIsAutomated(message)',
+      },
       showAvatar: {
         type: Boolean,
         computed: '_computeShowAvatar(author, config)',
       },
       showReplyButton: {
         type: Boolean,
-        computed: '_computeShowReplyButton(message)',
+        computed: '_computeShowReplyButton(message, _loggedIn)',
       },
       projectConfig: Object,
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     ready: function() {
       this.$.restAPI.getConfig().then(function(config) {
         this.config = config;
       }.bind(this));
+      this.$.restAPI.getLoggedIn().then(function(loggedIn) {
+        this._loggedIn = loggedIn;
+      }.bind(this));
     },
 
     _computeAuthor: function(message) {
@@ -75,8 +95,8 @@
       return !!(author && config && config.plugin && config.plugin.has_avatars);
     },
 
-    _computeShowReplyButton: function(message) {
-      return !!message.message;
+    _computeShowReplyButton: function(message, loggedIn) {
+      return !!message.message && loggedIn;
     },
 
     _commentsChanged: function(value) {
@@ -94,6 +114,14 @@
       this.expanded = false;
     },
 
+    _computeIsAutomated: function(message) {
+      return !!message.tag && message.tag.indexOf('autogenerated') === 0;
+    },
+
+    _computeIsHidden: function(hideAutomated, isAutomated) {
+      return hideAutomated && isAutomated;
+    },
+
     _computeClass: function(expanded, showAvatar) {
       var classes = [];
       classes.push(expanded ? 'expanded' : 'collapsed');
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index c90f58a..4615392 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -20,7 +20,6 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-<script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-message.html">
@@ -36,6 +35,9 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
       element = fixture('basic');
     });
 
@@ -85,5 +87,40 @@
       assert.equal(0, content.textContent.trim().indexOf(updatedBy.name));
     });
 
+    test('autogenerated prefix hiding', function() {
+      element.message = {
+        tag: 'autogenerated:gerrit:test',
+        updated: '2016-01-12 20:24:49.448000000',
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('tag that is not autogenerated prefix does not hide', function() {
+      element.message = {
+        tag: 'something',
+        updated: '2016-01-12 20:24:49.448000000',
+      };
+
+      assert.isFalse(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isFalse(element.hidden);
+    });
+
+    test('reply button hidden unless logged in', function() {
+      var message = {
+        'message': 'Uploaded patch set 1.',
+      };
+      assert.isFalse(element._computeShowReplyButton(message, false));
+      assert.isTrue(element._computeShowReplyButton(message, true));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index 3ae6b44..c80ba4a 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -43,9 +43,17 @@
     </style>
     <div class="header">
       <h3>Messages</h3>
-      <gr-button link on-tap="_handleExpandCollapseTap">
-        [[_computeExpandCollapseMessage(_expanded)]]
-      </gr-button>
+      <div>
+        <gr-button id="collapse-messages" link
+            on-tap="_handleExpandCollapseTap">
+          [[_computeExpandCollapseMessage(_expanded)]]
+        </gr-button>
+        <gr-button id="automatedMessageToggle" link
+            on-tap="_handleAutomatedMessageToggleTap"
+            hidden$="[[!_hasAutomatedMessages(messages)]]">
+          [[_computeAutomatedToggleText(_hideAutomated)]]
+        </gr-button>
+      </div>
     </div>
     <template
         is="dom-repeat"
@@ -55,6 +63,7 @@
           change-num="[[changeNum]]"
           message="[[message]]"
           comments="[[_computeCommentsForMessage(comments, message)]]"
+          hide-automated="[[_hideAutomated]]"
           project-config="[[projectConfig]]"
           show-reply-button="[[showReplyButtons]]"
           on-scroll-to="_handleScrollTo"
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index e7a0573..aa0703f 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -29,7 +29,6 @@
       },
       comments: Object,
       projectConfig: Object,
-      topMargin: Number,
       showReplyButtons: {
         type: Boolean,
         value: false,
@@ -39,6 +38,10 @@
         type: Boolean,
         value: false,
       },
+      _hideAutomated: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     scrollToMessage: function(messageID) {
@@ -52,7 +55,7 @@
            offsetParent = offsetParent.offsetParent) {
         top += offsetParent.offsetTop;
       }
-      window.scrollTo(0, top - this.topMargin);
+      window.scrollTo(0, top);
       this._highlightEl(el);
     },
 
@@ -112,32 +115,69 @@
       }
     },
 
+    _handleAutomatedMessageToggleTap: function(e) {
+      e.preventDefault();
+      this._hideAutomated = !this._hideAutomated;
+    },
+
     _handleScrollTo: function(e) {
       this.scrollToMessage(e.detail.message.id);
     },
 
+    _hasAutomatedMessages: function(messages) {
+      for (var i = 0; messages && i < messages.length; i++) {
+        if (messages[i].tag &&
+            messages[i].tag.indexOf('autogenerated') === 0) {
+          return true;
+        }
+      }
+      return false;
+    },
+
     _computeExpandCollapseMessage: function(expanded) {
       return expanded ? 'Collapse all' : 'Expand all';
     },
 
+    _computeAutomatedToggleText: function(hideAutomated) {
+      return hideAutomated ? 'Show automated' : 'Hide automated';
+    },
+
+    /**
+     * Computes message author's file comments for change's message.
+     * Method uses this.messages to find next message and relies on messages
+     * to be sorted by date field descending.
+     * @param {!Object} comments Hash of arrays of comments, filename as key.
+     * @param {!Object} message
+     * @return {!Object} Hash of arrays of comments, filename as key.
+     */
     _computeCommentsForMessage: function(comments, message) {
       if (message._index === undefined || !comments || !this.messages) {
         return [];
       }
-      var index = message._index;
       var messages = this.messages || [];
-      var msgComments = {};
-      var mDate = util.parseDate(message.date);
+      var index = message._index;
+      var authorId = message.author._account_id;
+      var mDate = util.parseDate(message.date).getTime();
+      // NB: Messages array has oldest messages first.
       var nextMDate;
-      if (index < messages.length - 1) {
-        nextMDate = util.parseDate(messages[index + 1].date);
+      if (index > 0) {
+        for (var i = index - 1; i >= 0; i--) {
+          if (messages[i].author._account_id === authorId) {
+            nextMDate = util.parseDate(messages[i].date).getTime();
+            break;
+          }
+        }
       }
+      var msgComments = {};
       for (var file in comments) {
         var fileComments = comments[file];
         for (var i = 0; i < fileComments.length; i++) {
-          var cDate = util.parseDate(fileComments[i].updated);
-          if (cDate >= mDate) {
-            if (nextMDate && cDate >= nextMDate) {
+          if (fileComments[i].author._account_id !== authorId) {
+            continue;
+          }
+          var cDate = util.parseDate(fileComments[i].updated).getTime();
+          if (cDate <= mDate) {
+            if (nextMDate && cDate <= nextMDate) {
               continue;
             }
             msgComments[file] = msgComments[file] || [];
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index 3cda480..704c638 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -20,7 +20,6 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-<script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-messages-list.html">
@@ -34,44 +33,32 @@
 <script>
   suite('gr-messages-list tests', function() {
     var element;
+    var messages;
+
+    var randomMessage = function(opt_params) {
+      var params = opt_params || {};
+      var author1 = {
+        _account_id: 1115495,
+        name: 'Andrew Bonventre',
+        email: 'andybons@chromium.org',
+      };
+      return {
+        id: params.id || Math.random().toString(),
+        date: params.date || '2016-01-12 20:28:33.038000',
+        message: params.message || Math.random().toString(),
+        _revision_number: params._revision_number || 1,
+        author: params.author || author1,
+      };
+    };
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
       element = fixture('basic');
-      element.messages = [
-        {
-          id: '47c43261_55aa2c41',
-          author: {
-            _account_id: 1115495,
-            name: 'Andrew Bonventre',
-            email: 'andybons@chromium.org',
-          },
-          date: '2016-01-12 20:24:49.448000000',
-          message: 'Uploaded patch set 1.',
-          _revision_number: 1
-        },
-        {
-          id: '47c43261_9593e420',
-          author: {
-            _account_id: 1115495,
-            name: 'Andrew Bonventre',
-            email: 'andybons@chromium.org',
-          },
-          date: '2016-01-12 20:28:33.038000000',
-          message: 'Patch Set 1:\n\n(1 comment)',
-          _revision_number: 1
-        },
-        {
-          id: '87b2aaf4_f73260c5',
-          author: {
-            _account_id: 1143760,
-            name: 'Mark Mentovai',
-            email: 'mark@chromium.org',
-          },
-          date: '2016-01-12 21:17:07.554000000',
-          message: 'Patch Set 1:\n\n(3 comments)',
-          _revision_number: 1
-        }
-      ];
+      messages = _.times(3, randomMessage);
+      element.messages = messages;
       flushAsynchronousOperations();
     });
 
@@ -91,7 +78,7 @@
         assert.isTrue(allMessageEls[i].expanded);
       }
 
-      MockInteractions.tap(element.$$('.header gr-button'));
+      MockInteractions.tap(element.$$('#collapse-messages'));
       allMessageEls =
           Polymer.dom(element.root).querySelectorAll('gr-message');
       for (var i = 0; i < allMessageEls.length; i++) {
@@ -99,6 +86,11 @@
       }
     });
 
+    test('hide messages does not appear when no automated messages',
+        function() {
+      assert.isOk(element.$$('#automatedMessageToggle[hidden]'));
+    });
+
     test('scroll to message', function() {
       var allMessageEls =
           Polymer.dom(element.root).querySelectorAll('gr-message');
@@ -116,7 +108,7 @@
             'expected gr-message ' + i + ' to not be expanded');
       }
 
-      var messageID = '47c43261_9593e420';
+      var messageID = messages[1].id;
       element.scrollToMessage(messageID);
       assert.isTrue(
           element.$$('[data-message-id="' + messageID + '"]').expanded);
@@ -127,5 +119,167 @@
       scrollToStub.restore();
       highlightStub.restore();
     });
+
+    test('messages', function() {
+      var author = {
+        _account_id: 42,
+        name: 'Marvin the Paranoid Android',
+        email: 'marvin@sirius.org',
+      };
+      var comments = {
+        file1: [
+          {
+            message: 'message text',
+            updated: '2016-09-27 00:18:03.000000000',
+            in_reply_to: '6505d749_f0bec0aa',
+            line: 62,
+            id: '6505d749_10ed44b2',
+            patch_set: 2,
+            author: {
+              email: 'some@email.com',
+              _account_id: 123,
+            },
+          },
+          {
+            message: 'message text',
+            updated: '2016-09-27 00:18:03.000000000',
+            in_reply_to: 'c5912363_6b820105',
+            line: 42,
+            id: '450a935e_0f1c05db',
+            patch_set: 2,
+            author: author,
+          },
+          {
+            message: 'message text',
+            updated: '2016-09-27 00:18:03.000000000',
+            in_reply_to: '6505d749_f0bec0aa',
+            line: 62,
+            id: '6505d749_10ed44b2',
+            patch_set: 2,
+            author: author,
+          },
+        ],
+        file2: [
+          {
+            message: 'message text',
+            updated: '2016-09-27 00:18:03.000000000',
+            in_reply_to: 'c5912363_4b7d450a',
+            line: 132,
+            id: '450a935e_4f260d25',
+            patch_set: 2,
+            author: author,
+          },
+        ]
+      };
+      var messages = [].concat(
+          randomMessage(),
+          {
+            _index: 5,
+            _revision_number: 4,
+            message: 'Uploaded patch set 4.',
+            date: '2016-09-28 13:36:33.000000000',
+            author: author,
+            id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
+          },
+          {
+            _index: 6,
+            _revision_number: 4,
+            message: 'Patch Set 4:\n\n(6 comments)',
+            date: '2016-09-28 13:36:33.000000000',
+            author: author,
+            id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
+          }
+      );
+      element.comments = comments;
+      element.messages = messages;
+      var isAuthor = function(author, message) {
+        return message.author._account_id === author._account_id;
+      };
+      var isMarvin = isAuthor.bind(null, author);
+      flushAsynchronousOperations();
+      var messageElements =
+          Polymer.dom(element.root).querySelectorAll('gr-message');
+      assert.equal(messageElements.length, messages.length);
+      assert.deepEqual(messageElements[1].message, messages[1]);
+      assert.deepEqual(messageElements[2].message, messages[2]);
+      assert.deepEqual(messageElements[1].comments.file1,
+          comments.file1.filter(isMarvin));
+      assert.deepEqual(messageElements[1].comments.file2,
+          comments.file2.filter(isMarvin));
+      assert.deepEqual(messageElements[2].comments, {});
+    });
+  });
+
+  suite('gr-messages-list automate tests', function() {
+    var element;
+    var messages;
+
+    var randomMessage = function(opt_params) {
+      var params = opt_params || {};
+      var author1 = {
+        _account_id: 1115495,
+        name: 'Andrew Bonventre',
+        email: 'andybons@chromium.org',
+      };
+      return {
+        id: params.id || Math.random().toString(),
+        date: params.date || '2016-01-12 20:28:33.038000',
+        message: params.message || Math.random().toString(),
+        _revision_number: params._revision_number || 1,
+        author: params.author || author1,
+        tag: 'autogenerated:gerrit:replace',
+      };
+    };
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
+      element = fixture('basic');
+      messages = _.times(3, randomMessage);
+      element.messages = messages;
+      flushAsynchronousOperations();
+    });
+
+    test('hide autogenerated button is not hidden', function() {
+      assert.isNotOk(element.$$('#automatedMessageToggle[hidden]'));
+    });
+
+    test('autogenerated messages are not hidden initially', function() {
+      var allHiddenMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message[hidden]');
+
+      //There are no hidden messages.
+      assert.isFalse(!!allHiddenMessageEls.length);
+    });
+
+    test('autogenerated messages are hidden after clicking hide button',
+        function() {
+      var allHiddenMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message[hidden]');
+
+      element._hideAutomated = false;
+      MockInteractions.tap(element.$$('#automatedMessageToggle'));
+      allHiddenMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message[hidden]');
+
+      //Autogenerated messages are now hidden.
+      assert.isTrue(!!allHiddenMessageEls.length);
+    });
+
+    test('autogenerated messages are not hidden after clicking show button',
+        function() {
+      var allHiddenMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message[hidden]');
+
+      element._hideAutomated = true;
+      MockInteractions.tap(element.$$('#automatedMessageToggle'));
+      allHiddenMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message[hidden]');
+
+      //Autogenerated messages are now hidden.
+      assert.isFalse(!!allHiddenMessageEls.length);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index 5d2d20d..adde31e 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -74,59 +74,61 @@
       }
     </style>
     <div hidden$="[[!_loading]]">Loading...</div>
-    <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
-      <h4>Relation chain</h4>
-      <template
-          is="dom-repeat"
-          items="[[_relatedResponse.changes]]"
-          as="related">
-        <div class$="[[_computeChangeContainerClass(change, related)]]">
-          <a href$="[[_computeChangeURL(related._change_number, related._revision_number)]]"
-              class$="[[_computeLinkClass(related)]]">
-            [[related.commit.subject]]
+    <div hidden$="[[_loading]]">
+      <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
+        <h4>Relation chain</h4>
+        <template
+            is="dom-repeat"
+            items="[[_relatedResponse.changes]]"
+            as="related">
+          <div class$="[[_computeChangeContainerClass(change, related)]]">
+            <a href$="[[_computeChangeURL(related._change_number, related._revision_number)]]"
+                class$="[[_computeLinkClass(related)]]">
+              [[related.commit.subject]]
+            </a>
+            <span class$="[[_computeChangeStatusClass(related)]]">
+              ([[_computeChangeStatus(related)]])
+            </span>
+          </div>
+        </template>
+      </section>
+      <section hidden$="[[!_submittedTogether.length]]" hidden>
+        <h4>Submitted together</h4>
+        <template is="dom-repeat" items="[[_submittedTogether]]" as="change">
+          <a href$="[[_computeChangeURL(change._number)]]"
+              class$="[[_computeLinkClass(change)]]">
+            [[change.project]]: [[change.branch]]: [[change.subject]]
           </a>
-          <span class$="[[_computeChangeStatusClass(related)]]">
-            ([[_computeChangeStatus(related)]])
-          </span>
-        </div>
-      </template>
-    </section>
-    <section hidden$="[[!_submittedTogether.length]]" hidden>
-      <h4>Submitted together</h4>
-      <template is="dom-repeat" items="[[_submittedTogether]]" as="change">
-        <a href$="[[_computeChangeURL(change._number)]]"
-            class$="[[_computeLinkClass(change)]]">
-          [[change.project]]: [[change.branch]]: [[change.subject]]
-        </a>
-      </template>
-    </section>
-    <section hidden$="[[!_sameTopic.length]]" hidden>
-      <h4>Same topic</h4>
-      <template is="dom-repeat" items="[[_sameTopic]]" as="change">
-        <a href$="[[_computeChangeURL(change._number)]]"
-            class$="[[_computeLinkClass(change)]]">
-          [[change.project]]: [[change.branch]]: [[change.subject]]
-        </a>
-      </template>
-    </section>
-    <section hidden$="[[!_conflicts.length]]" hidden>
-      <h4>Merge conflicts</h4>
-      <template is="dom-repeat" items="[[_conflicts]]" as="change">
-        <a href$="[[_computeChangeURL(change._number)]]"
-            class$="[[_computeLinkClass(change)]]">
-          [[change.subject]]
-        </a>
-      </template>
-    </section>
-    <section hidden$="[[!_cherryPicks.length]]" hidden>
-      <h4>Cherry picks</h4>
-      <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
-        <a href$="[[_computeChangeURL(change._number)]]"
-            class$="[[_computeLinkClass(change)]]">
-          [[change.subject]]
-        </a>
-      </template>
-    </section>
+        </template>
+      </section>
+      <section hidden$="[[!_sameTopic.length]]" hidden>
+        <h4>Same topic</h4>
+        <template is="dom-repeat" items="[[_sameTopic]]" as="change">
+          <a href$="[[_computeChangeURL(change._number)]]"
+              class$="[[_computeLinkClass(change)]]">
+            [[change.project]]: [[change.branch]]: [[change.subject]]
+          </a>
+        </template>
+      </section>
+      <section hidden$="[[!_conflicts.length]]" hidden>
+        <h4>Merge conflicts</h4>
+        <template is="dom-repeat" items="[[_conflicts]]" as="change">
+          <a href$="[[_computeChangeURL(change._number)]]"
+              class$="[[_computeLinkClass(change)]]">
+            [[change.subject]]
+          </a>
+        </template>
+      </section>
+      <section hidden$="[[!_cherryPicks.length]]" hidden>
+        <h4>Cherry picks</h4>
+        <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
+          <a href$="[[_computeChangeURL(change._number)]]"
+              class$="[[_computeLinkClass(change)]]">
+            [[change.subject]]
+          </a>
+        </template>
+      </section>
+    </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-related-changes-list.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index f4ee53a..cac45c6 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -68,7 +68,7 @@
         }.bind(this)),
       ];
 
-      return this._getServerConfig().then(function(config) {
+      promises.push(this._getServerConfig().then(function(config) {
         if (this.change.topic && !config.change.submit_whole_topic) {
           return this._getChangesWithSameTopic().then(function(response) {
             this._sameTopic = response;
@@ -77,7 +77,9 @@
           this._sameTopic = [];
         }
         return this._sameTopic;
-      }.bind(this)).then(Promise.all(promises)).then(function() {
+      }.bind(this)));
+
+      return Promise.all(promises).then(function() {
         this._loading = false;
       }.bind(this));
     },
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index cec1e90..bf8a95f1 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -97,9 +97,6 @@
         border: none;
         width: 100%;
       }
-      .labelsNotShown {
-        color: #666;
-      }
       .labelContainer:not(:first-of-type) {
         margin-top: .5em;
       }
@@ -211,28 +208,26 @@
         </iron-autogrow-textarea>
       </section>
       <section class="labelsContainer">
-        <template is="dom-if" if="[[_computeShowLabels(patchNum, revisions)]]">
-          <template is="dom-repeat"
-              items="[[_computeLabelArray(permittedLabels)]]" as="label">
-            <div class="labelContainer">
-              <span class="labelName">[[label]]</span>
-              <iron-selector data-label$="[[label]]"
-                  selected="[[_computeIndexOfLabelValue(labels, permittedLabels, label, _account)]]">
-                <template is="dom-repeat"
-                    items="[[_computePermittedLabelValues(permittedLabels, label)]]"
-                    as="value">
-                  <gr-button has-tooltip data-value$="[[value]]"
-                      title$="[[_computeLabelValueTitle(labels, label, value)]]">[[value]]</gr-button>
-                </template>
-              </iron-selector>
-            </div>
-          </template>
+        <template is="dom-if" if="[[_isClosed(change)]]" id="labelDisabled">
+          <div class="labelDisabledMessage">
+            Setting labels are disabled for this change because it has been
+            closed.
+          </div>
         </template>
-        <template is="dom-if" if="[[!_computeShowLabels(patchNum, revisions)]]">
-          <span class="labelsNotShown">
-            Labels are not shown because this is not the most recent patch set.
-            <a href$="/c/[[change._number]]">Go to the latest patch set.</a>
-          </span>
+        <template is="dom-repeat"
+            items="[[_labels]]" as="label">
+          <div class="labelContainer">
+            <span class="labelName">[[label.name]]</span>
+            <iron-selector data-label$="[[label.name]]"
+                selected="[[_computeIndexOfLabelValue(change.labels, permittedLabels, label)]]">
+              <template is="dom-repeat"
+                  items="[[_computePermittedLabelValues(permittedLabels, label.name)]]"
+                  as="value">
+                <gr-button has-tooltip data-value$="[[value]]"
+                    title$="[[_computeLabelValueTitle(change.labels, label.name, value)]]">[[value]]</gr-button>
+              </template>
+            </iron-selector>
+          </div>
         </template>
       </section>
       <section class="draftsContainer" hidden$="[[_computeHideDraftList(diffDrafts)]]">
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index d2b279d..c812537 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -23,6 +23,8 @@
     REVIEWERS: 'reviewers',
   };
 
+  var CLOSED_CHANGE_STATUSES = ['ABANDONED', 'MERGED'];
+
   Polymer({
     is: 'gr-reply-dialog',
 
@@ -48,7 +50,6 @@
     properties: {
       change: Object,
       patchNum: String,
-      revisions: Object,
       disabled: {
         type: Boolean,
         value: false,
@@ -59,6 +60,10 @@
         value: '',
         observer: '_draftChanged',
       },
+      quote: {
+        type: String,
+        value: '',
+      },
       diffDrafts: Object,
       filterReviewerSuggestion: {
         type: Function,
@@ -66,7 +71,6 @@
           return this._filterReviewerSuggestion.bind(this);
         },
       },
-      labels: Object,
       permittedLabels: Object,
       serverConfig: Object,
 
@@ -76,6 +80,10 @@
         type: Object,
         observer: '_reviewerPendingConfirmationUpdated',
       },
+      _labels: {
+        type: Array,
+        computed: '_computeLabels(change.labels.*, _account)',
+      },
       _owner: Object,
       _reviewers: Array,
       _reviewerPendingConfirmation: {
@@ -96,7 +104,7 @@
 
     attached: function() {
       this._getAccount().then(function(account) {
-        this._account = account;
+        this._account = account || {};
       }.bind(this));
     },
 
@@ -153,12 +161,21 @@
 
         var selectorEl = this.$$('iron-selector[data-label="' + label + '"]');
 
-        // The selector may not be present if it’s not at the latest patch set.
-        if (!selectorEl) { continue; }
+        // The user may have not voted on this label.
+        if (!selectorEl.selectedItem) { continue; }
 
         var selectedVal = selectorEl.selectedItem.getAttribute('data-value');
         selectedVal = parseInt(selectedVal, 10);
-        obj.labels[label] = selectedVal;
+
+        // Only send the selection if the user changed it.
+        var prevVal = this._getVoteForAccount(this.change.labels, label,
+            this._account);
+        if (prevVal !== null) {
+          prevVal = parseInt(prevVal, 10);
+        }
+        if (selectedVal !== prevVal) {
+          obj.labels[label] = selectedVal;
+        }
       }
       if (this.draft != null) {
         obj.message = this.draft;
@@ -259,14 +276,8 @@
       }.bind(this));
     },
 
-    _computeShowLabels: function(patchNum, revisions) {
-      var num = parseInt(patchNum, 10);
-      for (var rev in revisions) {
-        if (revisions[rev]._number > num) {
-          return false;
-        }
-      }
-      return true;
+    _isClosed: function(change) {
+      return CLOSED_CHANGE_STATUSES.indexOf(change.status) !== -1;
     },
 
     _computeHideDraftList: function(drafts) {
@@ -287,31 +298,36 @@
       return labels[label] && labels[label].values[value];
     },
 
-    _computeLabelArray: function(labelsObj) {
-      return Object.keys(labelsObj).sort();
+    _computeLabels: function(labelRecord) {
+      var labelsObj = labelRecord.base;
+      if (!labelsObj) { return []; }
+      return Object.keys(labelsObj).sort().map(function(key) {
+        return {
+          name: key,
+          value: this._getVoteForAccount(labelsObj, key, this._account),
+        };
+      }.bind(this));
     },
 
-    _computeIndexOfLabelValue: function(
-        labels, permittedLabels, labelName, account) {
-      var t = labels[labelName];
-      if (!t) { return null; }
-      var labelValue = t.default_value;
-
-      // Is there an existing vote for the current user? If so, use that.
+    _getVoteForAccount: function(labels, labelName, account) {
       var votes = labels[labelName];
       if (votes.all && votes.all.length > 0) {
         for (var i = 0; i < votes.all.length; i++) {
           if (votes.all[i]._account_id == account._account_id) {
-            labelValue = votes.all[i].value;
-            break;
+            return votes.all[i].value;
           }
         }
       }
+      return null;
+    },
 
-      var len = permittedLabels[labelName] != null ?
-          permittedLabels[labelName].length : 0;
+    _computeIndexOfLabelValue: function(labels, permittedLabels, label) {
+      if (!labels[label.name]) { return null; }
+      var labelValue = label.value;
+      var len = permittedLabels[label.name] != null ?
+          permittedLabels[label.name].length : 0;
       for (var i = 0; i < len; i++) {
-        var val = parseInt(permittedLabels[labelName][i], 10);
+        var val = parseInt(permittedLabels[label.name][i], 10);
         if (val == labelValue) {
           return i;
         }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index 8fb4e45..0e5eb25 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -20,7 +20,6 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-<script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-reply-dialog.html">
@@ -49,43 +48,46 @@
       patchNum = 1;
 
       stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
         getAccount: function() { return Promise.resolve({}); },
       });
 
       element = fixture('basic');
-      element.change = { _number: changeNum };
-      element.patchNum = patchNum;
-      element.labels = {
-        Verified: {
-          values: {
-            '-1': 'Fails',
-            ' 0': 'No score',
-            '+1': 'Verified'
+      element.change = {
+        _number: changeNum,
+        labels: {
+          Verified: {
+            values: {
+              '-1': 'Fails',
+              ' 0': 'No score',
+              '+1': 'Verified',
+            },
+            default_value: 0,
           },
-          default_value: 0
+          'Code-Review': {
+            values: {
+              '-2': 'Do not submit',
+              '-1': 'I would prefer that you didn\'t submit this',
+              ' 0': 'No score',
+              '+1': 'Looks good to me, but someone else must approve',
+              '+2': 'Looks good to me, approved',
+            },
+            default_value: 0,
+          },
         },
-        'Code-Review': {
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didn\'t submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved'
-          },
-          default_value: 0
-        }
       };
+      element.patchNum = patchNum;
       element.permittedLabels = {
         'Code-Review': [
           '-1',
           ' 0',
-          '+1'
+          '+1',
         ],
         Verified: [
           '-1',
           ' 0',
-          '+1'
-        ]
+          '+1',
+        ],
       };
       element.serverConfig = {};
 
@@ -102,23 +104,25 @@
       sandbox.restore();
     });
 
+    test('changes in label score are reflected in the DOM', function() {
+      element._account = {_account_id: 1};
+      element.set(['change', 'labels', 'Verified', 'all'],
+          [{_account_id: 1, value: -1}]);
+      flushAsynchronousOperations();
+      var selector = element.$$('iron-selector[data-label="Verified"]');
+      assert.equal(selector.selected, 0); // Index 0, value -1
+      element.set(['change', 'labels', 'Verified', 'all'],
+         [{_account_id: 1, value: 1}]);
+      flushAsynchronousOperations();
+      assert.equal(selector.selected, 2); // Index 2, value 1
+    });
+
     test('cancel event', function(done) {
       element.addEventListener('cancel', function() { done(); });
       MockInteractions.tap(element.$$('.cancel'));
     });
 
-    test('show/hide labels', function() {
-      var revisions = {
-        rev1: {_number: 1},
-        rev2: {_number: 2},
-      };
-      assert.isFalse(element._computeShowLabels('1', revisions));
-      assert.isTrue(element._computeShowLabels('2', revisions));
-    });
-
     test('label picker', function(done) {
-      var showLabelsStub = sinon.stub(element, '_computeShowLabels',
-          function() { return true; });
       element.revisions = {};
       element.patchNum = '';
 
@@ -143,7 +147,7 @@
             drafts: 'PUBLISH_ALL_REVISIONS',
             labels: {
               'Code-Review': -1,
-              'Verified': -1
+              'Verified': -1,
             },
             message: 'I wholeheartedly disapprove',
             reviewers: [],
@@ -156,7 +160,6 @@
               'Element should be enabled when done sending reply.');
           assert.equal(element.draft.length, 0);
           saveReviewStub.restore();
-          showLabelsStub.restore();
           done();
         });
 
@@ -232,7 +235,7 @@
       }).then(function() {
         assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
         observer = overlayObserver('closed');
-        MockInteractions.tap(yesButton); // confirm the group
+        MockInteractions.tap(yesButton); // Confirm the group.
         return observer;
       }).then(function() {
         assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
@@ -256,6 +259,18 @@
       }).then(done);
     });
 
+    test('message disabled dialogue appears for closed change', function() {
+      element.change = {status: 'ABANDONED'};
+      flushAsynchronousOperations();
+      assert.isOk(element.$$('.labelDisabledMessage'));
+    });
+
+    test('message disabled dialogue does not appear for open change',
+        function() {
+      element.change = {status: 'NEW'};
+      assert.isNotOk(element.$$('.labelDisabledMessage'));
+    });
+
     test('_getStorageLocation', function() {
       var actual = element._getStorageLocation();
       assert.equal(actual.changeNum, changeNum);
@@ -312,7 +327,10 @@
           assert.equal(body, 'first error, second error');
         });
       });
-      element.send().then(done);
+
+      // Async tick is needed because iron-selector content is distributed and
+      // distributed content requires an observer to be set up.
+      flush(function() { element.send().then(done); });
     });
 
     test('ccs are displayed if NoteDb is enabled', function() {
@@ -389,5 +407,74 @@
       assert.strictEqual(
           element._chooseFocusTarget(), element.FocusTarget.BODY);
     });
+
+    test('only send labels that have changed', function(done) {
+      flush(function() {
+        var saveReviewStub = sinon.stub(element, '_saveReview',
+            function(review) {
+          assert.deepEqual(review.labels, {Verified: -1});
+          return Promise.resolve({ok: true});
+        });
+
+        element.addEventListener('send', function() {
+          saveReviewStub.restore();
+          done();
+        });
+        // Without wrapping this test in flush(), the below two calls to
+        // MockInteractions.tap() cause a race in some situations in shadow DOM.
+        // The send button can be tapped before the others, causing the test to
+        // fail.
+        MockInteractions.tap(element.$$(
+            'iron-selector[data-label="Verified"] > ' +
+            'gr-button[data-value="-1"]'));
+        MockInteractions.tap(element.$$('.send'));
+      });
+    });
+
+    test('do not display tooltips on touch devices', function() {
+      element._account = {_account_id: 1};
+      element.set(['change', 'labels', 'Verified', 'all'],
+          [{_account_id: 1, value: -1}]);
+      element.labels = {
+        Verified: {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified'
+          },
+          default_value: 0
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didn\'t submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved'
+          },
+          default_value: 0
+        }
+      };
+
+      flushAsynchronousOperations();
+
+      var verifiedBtn = element.$$(
+          'iron-selector[data-label="Verified"] > ' +
+          'gr-button[data-value="-1"]');
+
+      // On touch devices, tooltips should not be shown.
+      verifiedBtn._isTouchDevice = true;
+      verifiedBtn._handleShowTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+      verifiedBtn._handleHideTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+
+      // On other devices, tooltips should be shown.
+      verifiedBtn._isTouchDevice = false;
+      verifiedBtn._handleShowTooltip();
+      assert.isOk(verifiedBtn._tooltip);
+      verifiedBtn._handleHideTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 6c6125c..d2ee15e 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -20,7 +20,6 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-<script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-reviewer-list.html">
@@ -40,6 +39,7 @@
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
         removeChangeReviewer: function() {
           return Promise.resolve({ok: true});
         },
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index 3ae3b14..8c39da8 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -35,6 +35,9 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
       element = fixture('basic');
     });
 
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 7a9c4f9..479f389 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -18,6 +18,7 @@
   var CHECK_SIGN_IN_INTERVAL_MS = 60000;
   var SIGN_IN_WIDTH_PX = 690;
   var SIGN_IN_HEIGHT_PX = 500;
+  var TOO_MANY_FILES = 'too many files to find conflicts';
 
   Polymer({
     is: 'gr-error-manager',
@@ -38,6 +39,10 @@
       this.unlisten(document, 'network-error', '_handleNetworkError');
     },
 
+    _shouldSuppressError: function(msg) {
+      return msg.indexOf(TOO_MANY_FILES) > -1;
+    },
+
     _handleServerError: function(e) {
       if (e.detail.response.status === 403) {
         this._getLoggedIn().then(function(loggedIn) {
@@ -49,7 +54,9 @@
         }.bind(this));
       } else {
         e.detail.response.text().then(function(text) {
-          this._showAlert('Server error: ' + text);
+          if (!this._shouldSuppressError(text)) {
+            this._showAlert('Server error: ' + text);
+          }
         }.bind(this));
       }
     },
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index f633a7e..44cbde0 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -70,6 +70,20 @@
       });
     });
 
+    test('suppress TOO_MANY_FILES error', function(done) {
+      var showAlertStub = sandbox.stub(element, '_showAlert');
+      var textSpy = sandbox.spy(function() {
+        return Promise.resolve('too many files to find conflicts');
+      });
+      element.fire('server-error', {response: {status: 500, text: textSpy}});
+
+      assert.isTrue(textSpy.called);
+      textSpy.lastCall.returnValue.then(function() {
+        assert.isFalse(showAlertStub.called);
+        done();
+      });
+    });
+
     test('show network error', function(done) {
       var consoleErrorStub = sandbox.stub(console, 'error');
       var showAlertStub = sandbox.stub(element, '_showAlert');
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index 7291199..7a7e761 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -98,13 +98,6 @@
             <td><span class="key">u</span></td>
             <td>Up to change list</td>
           </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">i</span>
-            </td>
-            <td>Show/hide inline diffs</td>
-          </tr>
         </tbody>
         <!-- Diff View -->
         <tbody hidden$="[[!_computeInView(view, 'gr-diff-view')]]" hidden>
@@ -155,7 +148,11 @@
           </tr>
           <tr>
             <td><span class="key">a</span></td>
-            <td>Review and publish comments</td>
+            <td>Open reply dialog to publish comments and add reviewers</td>
+          </tr>
+          <tr>
+            <td><span class="key">d</span></td>
+            <td>Open download overlay</td>
           </tr>
           <tr>
             <td></td><td class="header">File list</td>
@@ -173,6 +170,17 @@
             <td>Show selected file</td>
           </tr>
           <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">i</span>
+            </td>
+            <td>Show/hide all inline diffs</td>
+          </tr>
+          <tr>
+            <td><span class="key">i</span></td>
+            <td>Show/hide selected inline diff</td>
+          </tr>
+          <tr>
             <td></td><td class="header">Diffs</td>
           </tr>
           <tr>
@@ -206,6 +214,17 @@
             <td>Go to previous comment thread</td>
           </tr>
           <tr>
+            <td><span class="key">e</span></td>
+            <td>Expand all comment threads</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">e</span>
+            </td>
+            <td>Collapse all comment threads</td>
+          </tr>
+          <tr>
             <td>
               <span class="key modifier">Shift</span>
               <span class="key">←</span>
@@ -269,6 +288,17 @@
             <td>Show previous comment thread</td>
           </tr>
           <tr>
+            <td><span class="key">e</span></td>
+            <td>Expand all comment threads</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">e</span>
+            </td>
+            <td>Collapse all comment threads</td>
+          </tr>
+          <tr>
             <td>
               <span class="key modifier">Shift</span>
               <span class="key">←</span>
@@ -297,7 +327,7 @@
           </tr>
           <tr>
             <td><span class="key">a</span></td>
-            <td>Review and publish comments</td>
+            <td>Open reply dialog to publish comments and add reviewers</td>
           </tr>
           <tr>
             <td><span class="key">,</span></td>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index 930c8cf..3e30810 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -45,34 +45,38 @@
       .links {
         margin-left: 1em;
       }
-      .links ul {
+      .links .menuContainer {
         display: none;
       }
       .links > li {
         cursor: default;
         display: inline-block;
         margin-left: 1em;
-        padding: .4em 0;
+        padding: .5em 0;
         position: relative;
       }
-      .links li:hover ul {
+      .links li:hover .menuContainer,
+      .links li:active .menuContainer {
         background-color: #fff;
+        border-radius: 3px;
         box-shadow: 0 1px 1px rgba(0, 0, 0, .3);
         display: block;
-        left: -.75em;
+        left: -.5em;
+        padding: .5em 0;
         position: absolute;
-        top: 2em;
+        top: 2.4em;
         z-index: 1000;
       }
       .links li ul li a:link,
       .links li ul li a:visited {
         color: #00e;
         display: block;
-        padding: .5em .75em;
+        padding: .3em 1em;
         text-decoration: none;
         white-space: nowrap;
       }
-      .links li ul li:hover a {
+      .links li ul li:hover a,
+      .links li ul li:active a {
         background-color: var(--selection-background-color);
       }
       .linksTitle {
@@ -87,7 +91,8 @@
         height: 0;
         position: absolute;
         right: 0;
-        top: calc(50% - .1em);
+        top: calc(50% - .05em);
+        transition: border-top-color 200ms;
         width: 0;
       }
       .links li:hover .downArrow {
@@ -137,11 +142,13 @@
             <span class="linksTitle">
               [[linkGroup.title]] <i class="downArrow"></i>
             </span>
-            <ul>
-              <template is="dom-repeat" items="[[linkGroup.links]]" as="link">
-                <li><a href$="[[link.url]]">[[link.name]]</a></li>
-              </template>
-            </ul>
+            <div class="menuContainer">
+              <ul>
+                <template is="dom-repeat" items="[[linkGroup.links]]" as="link">
+                  <li><a href$="[[link.url]]">[[link.name]]</a></li>
+                </template>
+              </ul>
+            </div>
           </li>
         </template>
       </ul>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 6fc3cc1..2f017de 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -79,6 +79,10 @@
       this.unlisten(window, 'location-change', '_handleLocationChange');
     },
 
+    reload: function() {
+      this._loadAccount();
+    },
+
     _handleLocationChange: function(e) {
       this._loginURL = '/login/' + encodeURIComponent(
           window.location.pathname +
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
index 0b40d87..aef338b 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
@@ -35,6 +35,9 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
       stub('gr-main-header', {
         _loadAccount: function() {},
       });
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
new file mode 100644
index 0000000..d10567c
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
@@ -0,0 +1,22 @@
+<!--
+Copyright (C) 2016 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+
+<dom-module id="gr-reporting">
+  <script src="gr-reporting.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
new file mode 100644
index 0000000..9c0e902
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -0,0 +1,153 @@
+// Copyright (C) 2016 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.
+(function() {
+  'use strict';
+
+  // Latency reporting constants.
+  var TIMING = {
+    TYPE: 'timing-report',
+    CATEGORY: 'UI Latency',
+    // Reported events - alphabetize below.
+    APP_STARTED: 'App Started',
+    PAGE_LOADED: 'Page Loaded',
+  };
+
+  // Navigation reporting constants.
+  var NAVIGATION = {
+    TYPE: 'nav-report',
+    CATEGORY: 'Location Changed',
+    PAGE: 'Page',
+  };
+
+  var CHANGE_VIEW_REGEX = /^\/c\/\d+\/?\d*$/;
+  var DIFF_VIEW_REGEX = /^\/c\/\d+\/\d+\/.+$/;
+
+  var pending = [];
+
+  Polymer({
+    is: 'gr-reporting',
+
+    properties: {
+      _baselines: {
+        type: Array,
+        value: function() { return {}; },
+      },
+    },
+
+    get performanceTiming() {
+      return window.performance.timing;
+    },
+
+    now: function() {
+      return Math.round(10 * window.performance.now()) / 10;
+    },
+
+    reporter: function() {
+      var report = (Gerrit._arePluginsLoaded() && !pending.length) ?
+        this.defaultReporter : this.cachingReporter;
+      report.apply(this, arguments);
+    },
+
+    defaultReporter: function(type, category, eventName, eventValue) {
+      var detail = {
+        type: type,
+        category: category,
+        name: eventName,
+        value: eventValue,
+      };
+      document.dispatchEvent(new CustomEvent(type, {detail: detail}));
+      console.log(eventName + ': ' + eventValue);
+    },
+
+    cachingReporter: function(type, category, eventName, eventValue) {
+      if (Gerrit._arePluginsLoaded()) {
+        if (pending.length) {
+          pending.splice(0).forEach(function(args) {
+            this.reporter.apply(this, args);
+          }, this);
+        }
+        this.reporter(type, category, eventName, eventValue);
+      } else {
+        pending.push([type, category, eventName, eventValue]);
+      }
+    },
+
+    /**
+     * User-perceived app start time, should be reported when the app is ready.
+     */
+    appStarted: function() {
+      var startTime =
+          new Date().getTime() - this.performanceTiming.navigationStart;
+      this.reporter(
+          TIMING.TYPE, TIMING.CATEGORY, TIMING.APP_STARTED, startTime);
+    },
+
+    /**
+     * Page load time, should be reported at any time after navigation.
+     */
+    pageLoaded: function() {
+      if (this.performanceTiming.loadEventEnd === 0) {
+        console.error('pageLoaded should be called after window.onload');
+        this.async(this.pageLoaded, 100);
+      } else {
+        var loadTime = this.performanceTiming.loadEventEnd -
+            this.performanceTiming.navigationStart;
+        this.reporter(
+          TIMING.TYPE, TIMING.CATEGORY, TIMING.PAGE_LOADED, loadTime);
+      }
+    },
+
+    locationChanged: function() {
+      var page = '';
+      var pathname = this._getPathname();
+      if (pathname.indexOf('/q/') === 0) {
+        page = '/q/';
+      } else if (pathname.match(CHANGE_VIEW_REGEX)) { // change view
+        page = '/c/';
+      } else if (pathname.match(DIFF_VIEW_REGEX)) { // diff view
+        page = '/c//COMMIT_MSG';
+      } else {
+        // Ignore other page changes.
+        return;
+      }
+      this.reporter(
+          NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page);
+    },
+
+    pluginsLoaded: function() {
+      this.timeEnd('PluginsLoaded');
+    },
+
+    _getPathname: function() {
+      return window.location.pathname;
+    },
+
+    /**
+     * Reset named timer.
+     */
+    time: function(name) {
+      this._baselines[name] = this.now();
+    },
+
+    /**
+     * Finish named timer and report it to server.
+     */
+    timeEnd: function(name) {
+      var baseTime = this._baselines[name] || 0;
+      var time = this.now() - baseTime;
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY, name, time);
+      delete this._baselines[name];
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
new file mode 100644
index 0000000..85b1119
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -0,0 +1,174 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reporting</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-reporting.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-reporting></gr-reporting>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-reporting tests', function() {
+    var element;
+    var sandbox;
+    var clock;
+    var fakePerformance;
+
+    var NOW_TIME = 100;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+      clock = sinon.useFakeTimers(NOW_TIME);
+      element = fixture('basic');
+      fakePerformance = {
+        navigationStart: 1,
+        loadEventEnd: 2,
+      };
+      sinon.stub(element, 'performanceTiming',
+          {get: function() {return fakePerformance;}});
+      sandbox.stub(element, 'reporter');
+    });
+    teardown(function() {
+      sandbox.restore();
+      clock.restore();
+    });
+
+    test('appStarted', function() {
+      element.appStarted();
+      assert.isTrue(
+          element.reporter.calledWithExactly(
+              'timing-report', 'UI Latency', 'App Started',
+              NOW_TIME - fakePerformance.navigationStart
+      ));
+    });
+
+    test('pageLoaded', function() {
+      element.pageLoaded();
+      assert.isTrue(
+          element.reporter.calledWithExactly(
+              'timing-report', 'UI Latency', 'Page Loaded',
+              fakePerformance.loadEventEnd - fakePerformance.navigationStart)
+      );
+    });
+
+    test('time and timeEnd', function() {
+      var nowStub = sandbox.stub(element, 'now').returns(0);
+      element.time('foo');
+      nowStub.returns(1);
+      element.time('bar');
+      nowStub.returns(2);
+      element.timeEnd('bar');
+      nowStub.returns(3.123);
+      element.timeEnd('foo');
+      assert.isTrue(element.reporter.calledWithExactly(
+          'timing-report', 'UI Latency', 'foo', 3.123
+      ));
+      assert.isTrue(element.reporter.calledWithExactly(
+          'timing-report', 'UI Latency', 'bar', 1
+      ));
+    });
+
+    suite('plugins', function() {
+      setup(function() {
+        element.reporter.restore();
+        sandbox.stub(element, 'defaultReporter');
+        sandbox.stub(Gerrit, '_arePluginsLoaded');
+      });
+
+      test('pluginsLoaded reports time', function() {
+        Gerrit._arePluginsLoaded.returns(true);
+        sandbox.stub(element, 'now').returns(42);
+        element.pluginsLoaded();
+        assert.isTrue(element.defaultReporter.calledWithExactly(
+            'timing-report', 'UI Latency', 'PluginsLoaded', 42
+        ));
+      });
+
+      test('caches reports if plugins are not loaded', function() {
+        Gerrit._arePluginsLoaded.returns(false);
+        element.timeEnd('foo');
+        assert.isFalse(element.defaultReporter.called);
+      });
+
+      test('reports if plugins are loaded', function() {
+        Gerrit._arePluginsLoaded.returns(true);
+        element.timeEnd('foo');
+        assert.isTrue(element.defaultReporter.called);
+      });
+
+      test('reports cached events preserving order', function() {
+        Gerrit._arePluginsLoaded.returns(false);
+        element.timeEnd('foo');
+        Gerrit._arePluginsLoaded.returns(true);
+        element.timeEnd('bar');
+        assert.isTrue(element.defaultReporter.firstCall.calledWith(
+            'timing-report', 'UI Latency', 'foo'
+        ));
+        assert.isTrue(element.defaultReporter.secondCall.calledWith(
+            'timing-report', 'UI Latency', 'bar'
+        ));
+      });
+    });
+
+    suite('location changed', function() {
+      var pathnameStub;
+      setup(function() {
+        pathnameStub = sinon.stub(element, '_getPathname');
+      });
+
+      teardown(function() {
+        pathnameStub.restore();
+      });
+
+      test('search', function() {
+        pathnameStub.returns('/q/foo');
+        element.locationChanged();
+        assert.isTrue(element.reporter.calledWithExactly(
+            'nav-report', 'Location Changed', 'Page', '/q/'));
+      });
+
+      test('change view', function() {
+        pathnameStub.returns('/c/42/');
+        element.locationChanged();
+        assert.isTrue(element.reporter.calledWithExactly(
+            'nav-report', 'Location Changed', 'Page', '/c/'));
+      });
+
+      test('change view', function() {
+        pathnameStub.returns('/c/41/2');
+        element.locationChanged();
+        assert.isTrue(element.reporter.calledWithExactly(
+            'nav-report', 'Location Changed', 'Page', '/c/'));
+      });
+
+      test('diff view', function() {
+        pathnameStub.returns('/c/41/2/file.txt');
+        element.locationChanged();
+        assert.isTrue(element.reporter.calledWithExactly(
+            'nav-report', 'Location Changed', 'Page', '/c//COMMIT_MSG'));
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.html b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
index 2971ed2..4ad2a37 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
@@ -15,6 +15,7 @@
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-reporting/gr-reporting.html">
 
 <script src="../../../bower_components/page/page.js"></script>
 <script src="gr-router.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index d11d438..85e72c0 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -17,9 +17,18 @@
   // Polymer makes `app` intrinsically defined on the window by virtue of the
   // custom element having the id "app", but it is made explicit here.
   var app = document.querySelector('#app');
-  var restAPI = document.createElement('gr-rest-api-interface');
+  if (!app) {
+    console.log('No gr-app found (running tests)');
+    return;
+  }
 
   window.addEventListener('WebComponentsReady', function() {
+    var restAPI = document.createElement('gr-rest-api-interface');
+    var reporting = document.createElement('gr-reporting');
+
+    reporting.timeEnd('WebComponentsReady');
+    reporting.pageLoaded();
+
     // Middleware
     page(function(ctx, next) {
       document.body.scrollTop = 0;
@@ -27,7 +36,11 @@
       // Fire asynchronously so that the URL is changed by the time the event
       // is processed.
       app.async(function() {
-        app.fire('location-change');
+        app.fire('location-change', {
+          hash: window.location.hash,
+          pathname: window.location.pathname,
+        });
+        reporting.locationChanged();
       }, 1);
       next();
     });
@@ -46,6 +59,11 @@
       }
       // For backward compatibility with GWT links.
       if (data.hash) {
+        // In certain login flows the server may redirect to a hash without
+        // a leading slash, which page.js doesn't handle correctly.
+        if (data.hash[0] !== '/') {
+          data.hash = '/' + data.hash;
+        }
         page.redirect(data.hash);
         return;
       }
@@ -124,14 +142,31 @@
       };
       // Don't allow diffing the same patch number against itself.
       if (params.basePatchNum === params.patchNum) {
+        // TODO(kaspern): Utilize gr-url-encoding-behavior.html when the router
+        // is replaced with a Polymer counterpart.
+        // @see Issue 4255 regarding double-encoding.
+        var path = encodeURIComponent(encodeURIComponent(path));
+        // @see Issue 4577 regarding more readable URLs.
+        path = path.replace(/%252F/g, '/');
+        path = path.replace(/%2520/g, '+');
+
         page.redirect('/c/' +
             encodeURIComponent(params.changeNum) +
             '/' +
             encodeURIComponent(params.patchNum) +
             '/' +
-            encodeURIComponent(params.path));
+            path);
         return;
       }
+
+      // Check if path has an '@' which indicates it was using GWT style line
+      // numbers. Even if the filename had an '@' in it, it would have already
+      // been URI encoded. Redirect to hash version of path.
+      if (ctx.path.indexOf('@') !== -1) {
+        page.redirect(ctx.path.replace('@', '#'));
+        return;
+      }
+
       normalizePatchRangeParams(params);
       app.params = params;
     });
@@ -146,6 +181,12 @@
       });
     });
 
+    page(/^\/register(\/.*)?/, function(ctx) {
+      app.params = {justRegistered: true};
+      var path = ctx.params[0] || '/';
+      page.show(path);
+    });
+
     page.start();
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
index fecb376..a79e830 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
@@ -14,10 +14,13 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
 
 <dom-module id="gr-search-bar">
   <template>
@@ -51,8 +54,10 @@
           on-commit="_handleInputCommit"
           allowNonSuggestedValues
           multi
-          borderless></gr-autocomplete>
+          borderless
+          tab-complete-without-commit></gr-autocomplete>
       <gr-button id="searchButton">Search</gr-button>
+      <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     </form>
   </template>
   <script src="gr-search-bar.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 8e52f8f..9caed8c 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -78,11 +78,16 @@
     'tr',
   ];
 
+  var MAX_AUTOCOMPLETE_RESULTS = 10;
+
+  var TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
+
   Polymer({
     is: 'gr-search-bar',
 
     behaviors: [
       Gerrit.KeyboardShortcutBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     listeners: {
@@ -117,45 +122,178 @@
       this._preventDefaultAndNavigateToInputVal(e);
     },
 
+    /**
+     * This function is called in a few different cases:
+     *   - e.target is the search button
+     *   - e.target is the gr-autocomplete widget (#searchInput)
+     *   - e.target is the input element wrapped within #searchInput
+     *
+     * @param {!Event} e
+     */
     _preventDefaultAndNavigateToInputVal: function(e) {
       e.preventDefault();
-      Polymer.dom(e).rootTarget.blur();
-      // @see Issue 4255.
-      page.show('/q/' + encodeURIComponent(encodeURIComponent(this._inputVal)));
+      var target = Polymer.dom(e).rootTarget;
+      // If the target is the #searchInput or has a sub-input component, that
+      // is what holds the focus as opposed to the target from the DOM event.
+      if (target.$.input) {
+        target.$.input.blur();
+      } else {
+        target.blur();
+      }
+      if (this._inputVal) {
+        page.show('/q/' + this.encodeURL(this._inputVal, false));
+      }
     },
 
-    // TODO(kaspern): Flesh this out better.
-    _makeSuggestion: function(str) {
-      return {
-        name: str,
-        value: str,
-      };
+    /**
+     * Fetch from the API the predicted accounts.
+     * @param {string} predicate - The first part of the search term, e.g.
+     *     'owner'
+     * @param {string} expression - The second part of the search term, e.g.
+     *     'kasp'
+     * @return {!Promise} This returns a promise that resolves to an array of
+     *     strings.
+     */
+    _fetchAccounts: function(predicate, expression) {
+      if (expression.length === 0) { return Promise.resolve([]); }
+      return this.$.restAPI.getSuggestedAccounts(
+          expression,
+          MAX_AUTOCOMPLETE_RESULTS)
+          .then(function(accounts) {
+            if (!accounts) { return []; }
+            return accounts.map(function(acct) {
+              return predicate + ':"' + acct.name + ' <' + acct.email + '>"';
+            });
+          });
     },
 
-    // TODO(kaspern): Expand support for more complicated autocomplete features.
+    /**
+     * Fetch from the API the predicted groups.
+     * @param {string} predicate - The first part of the search term, e.g.
+     *     'ownerin'
+     * @param {string} expression - The second part of the search term, e.g.
+     *     'polyger'
+     * @return {!Promise} This returns a promise that resolves to an array of
+     *     strings.
+     */
+    _fetchGroups: function(predicate, expression) {
+      if (expression.length === 0) { return Promise.resolve([]); }
+      return this.$.restAPI.getSuggestedGroups(
+          expression,
+          MAX_AUTOCOMPLETE_RESULTS)
+          .then(function(groups) {
+            if (!groups) { return []; }
+            var keys = Object.keys(groups);
+            return keys.map(function(key) { return predicate + ':' + key; });
+          });
+    },
+
+    /**
+     * Fetch from the API the predicted projects.
+     * @param {string} predicate - The first part of the search term, e.g.
+     *     'project'
+     * @param {string} expression - The second part of the search term, e.g.
+     *     'gerr'
+     * @return {!Promise} This returns a promise that resolves to an array of
+     *     strings.
+     */
+    _fetchProjects: function(predicate, expression) {
+      return this.$.restAPI.getSuggestedProjects(
+          expression,
+          MAX_AUTOCOMPLETE_RESULTS)
+          .then(function(projects) {
+            if (!projects) { return []; }
+            var keys = Object.keys(projects);
+            return keys.map(function(key) { return predicate + ':' + key; });
+          });
+    },
+
+    /**
+     * Determine what array of possible suggestions should be provided
+     *     to _getSearchSuggestions.
+     * @param {string} input - The full search term, in lowercase.
+     * @return {!Promise} This returns a promise that resolves to an array of
+     *     strings.
+     */
+    _fetchSuggestions: function(input) {
+      // Split the input on colon to get a two part predicate/expression.
+      var splitInput = input.split(':');
+      var predicate = splitInput[0];
+      var expression = splitInput[1] || '';
+      // Switch on the predicate to determine what to autocomplete.
+      switch (predicate) {
+        case 'ownerin':
+        case 'reviewerin':
+          // Fetch groups.
+          return this._fetchGroups(predicate, expression);
+
+        case 'parentproject':
+        case 'project':
+          // Fetch projects.
+          return this._fetchProjects(predicate, expression);
+
+        case 'author':
+        case 'commentby':
+        case 'committer':
+        case 'from':
+        case 'owner':
+        case 'reviewedby':
+        case 'reviewer':
+          // Fetch accounts.
+          return this._fetchAccounts(predicate, expression);
+
+        default:
+          return Promise.resolve(SEARCH_OPERATORS
+              .filter(function(operator) {
+                return operator.indexOf(input) !== -1;
+              }));
+      }
+    },
+
+    /**
+     * Get the sorted, pruned list of suggestions for the current search query.
+     * @param {string} input - The complete search query.
+     * @return {!Promise} This returns a promise that resolves to an array of
+     *     strings.
+     */
     _getSearchSuggestions: function(input) {
-      return Promise.resolve(SEARCH_OPERATORS).then(function(operators) {
-        if (!operators) { return []; }
-        var lowerCaseInput = input
-            .substring(input.lastIndexOf(' ') + 1)
-            .toLowerCase();
-        return operators
-            .filter(function(operator) {
-              // Disallow autocomplete values that exactly match the whole str.
-              var opContainsInput = operator.indexOf(lowerCaseInput) !== -1;
-              var inputContainsOp = lowerCaseInput.indexOf(operator) !== -1;
-              return opContainsInput && !inputContainsOp;
-            })
-            // Prioritize results that start with the input.
-            .sort(function(operator) {
-              return operator.indexOf(lowerCaseInput);
-            })
-            .map(this._makeSuggestion);
-      }.bind(this));
+      // Allow spaces within quoted terms.
+      var tokens = input.match(TOKENIZE_REGEX);
+      var trimmedInput = tokens[tokens.length - 1].toLowerCase();
+
+      return this._fetchSuggestions(trimmedInput)
+          .then(function(operators) {
+            if (!operators || !operators.length) { return []; }
+            return operators
+                // Prioritize results that start with the input.
+                .sort(function(a, b) {
+                  var aContains = a.toLowerCase().indexOf(trimmedInput);
+                  var bContains = b.toLowerCase().indexOf(trimmedInput);
+                  if (aContains === bContains) {
+                    return a.localeCompare(b);
+                  }
+                  if (aContains === -1) {
+                    return 1;
+                  }
+                  if (bContains === -1) {
+                    return -1;
+                  }
+                  return aContains - bContains;
+                })
+                // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results.
+                .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1)
+                // Map to an object to play nice with gr-autocomplete.
+                .map(function(operator) {
+                  return {
+                    name: operator,
+                    value: operator,
+                  };
+                });
+          });
     },
 
     _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       switch (e.keyCode) {
         case 191:  // '/' or '?' with shift key.
           // TODO(andybons): Localization using e.key/keypress event.
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
index 0c16774..696efcd 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -58,6 +58,7 @@
         assert.notEqual(getActiveElement(), element.$.searchButton);
         done();
       });
+      element.value = 'test';
       MockInteractions.tap(element.$.searchButton);
     });
 
@@ -68,6 +69,7 @@
         assert.notEqual(getActiveElement(), element.$.searchButton);
         done();
       });
+      element.value = 'test';
       MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
     });
 
@@ -79,22 +81,110 @@
       showStub.restore();
     });
 
-    test('_getSearchSuggestions returns proper set of suggestions',
-        function(done) {
-      element._getSearchSuggestions('is:o')
-          .then(function(suggestions) {
-            assert.equal(suggestions[0].name, 'is:open');
-            assert.equal(suggestions[0].value, 'is:open');
-            assert.equal(suggestions[1].name, 'is:owner');
-            assert.equal(suggestions[1].value, 'is:owner');
-          })
-          .then(function() {
-            element._getSearchSuggestions('asdasdasdasd')
-                .then(function(suggestions) {
-                  assert.equal(suggestions.length, 0);
-                  done();
-                });
+    test('input blurred after commit', function() {
+      var showStub = sinon.stub(page, 'show');
+      var blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
+      element.$.searchInput.text = 'fate/stay';
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
+      assert.isTrue(blurSpy.called);
+      showStub.restore();
+      blurSpy.restore();
+    });
+
+    test('empty search query does not trigger nav', function() {
+      var showSpy = sinon.spy(page, 'show');
+      element.value = '';
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
+      assert.isFalse(showSpy.called);
+    });
+
+    suite('_getSearchSuggestions',
+        function() {
+      setup(function() {
+        sinon.stub(element.$.restAPI, 'getSuggestedAccounts', function() {
+          return Promise.resolve([
+            {
+              name: 'fred',
+              email: 'fred@goog.co',
+            },
+          ]);
+        });
+        sinon.stub(element.$.restAPI, 'getSuggestedGroups', function() {
+          return Promise.resolve({
+            Polygerrit: 0,
+            gerrit: 0,
+            gerrittest: 0,
           });
+        });
+        sinon.stub(element.$.restAPI, 'getSuggestedProjects', function() {
+          return Promise.resolve({
+            Polygerrit: 0,
+          });
+        });
+      });
+
+      teardown(function() {
+        element.$.restAPI.getSuggestedAccounts.restore();
+        element.$.restAPI.getSuggestedGroups.restore();
+        element.$.restAPI.getSuggestedProjects.restore();
+      });
+
+      test('Autocompletes accounts',
+          function(done) {
+        return element._getSearchSuggestions('owner:fr')
+            .then(function(suggestions) {
+              assert.equal(suggestions[0].value, 'owner:"fred <fred@goog.co>"');
+              done();
+            });
+      });
+
+      test('Autocompletes groups',
+          function(done) {
+        return element._getSearchSuggestions('ownerin:pol')
+            .then(function(suggestions) {
+              assert.equal(suggestions[0].value, 'ownerin:Polygerrit');
+              done();
+            });
+      });
+
+      test('Autocompletes projects',
+          function(done) {
+        return element._getSearchSuggestions('project:pol')
+            .then(function(suggestions) {
+              assert.equal(suggestions[0].value, 'project:Polygerrit');
+              done();
+            });
+      });
+
+      test('Autocompletes simple searches',
+          function(done) {
+        return element._getSearchSuggestions('is:o')
+            .then(function(suggestions) {
+              assert.equal(suggestions[0].name, 'is:open');
+              assert.equal(suggestions[0].value, 'is:open');
+              assert.equal(suggestions[1].name, 'is:owner');
+              assert.equal(suggestions[1].value, 'is:owner');
+              done();
+            });
+      });
+
+      test('Does not autocomplete with no match',
+          function(done) {
+        return element._getSearchSuggestions('asdasdasdasd')
+            .then(function(suggestions) {
+              assert.equal(suggestions.length, 0);
+              done();
+            });
+      });
+
+      test('Autocomplete doesnt override exact matches to input',
+          function(done) {
+        return element._getSearchSuggestions('ownerin:gerrit')
+            .then(function(suggestions) {
+              assert.equal(suggestions[0].value, 'ownerin:gerrit');
+              done();
+            });
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index 1cb8cc7..23c5036 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -34,6 +34,29 @@
     return sectionEl;
   };
 
+  GrDiffBuilderSideBySide.prototype.addColumns = function(outputEl, fontSize) {
+    var width = fontSize * 4;
+    var colgroup = document.createElement('colgroup');
+
+    // Add left-side line number.
+    var col = document.createElement('col');
+    col.setAttribute('width', width);
+    colgroup.appendChild(col);
+
+    // Add left-side content.
+    colgroup.appendChild(document.createElement('col'));
+
+    // Add right-side line number.
+    col = document.createElement('col');
+    col.setAttribute('width', width);
+    colgroup.appendChild(col);
+
+    // Add right-side content.
+    colgroup.appendChild(document.createElement('col'));
+
+    outputEl.appendChild(colgroup);
+  };
+
   GrDiffBuilderSideBySide.prototype._createRow = function(section, leftLine,
       rightLine) {
     var row = this._createElement('tr');
@@ -69,7 +92,6 @@
   GrDiffBuilderSideBySide.prototype._getNextContentOnSide = function(
       content, side) {
     var tr = content.parentElement.parentElement;
-    var content;
     while (tr = tr.nextSibling) {
       content = tr.querySelector(
           'td.content .contentText[data-side="' + side + '"]');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 960bf46..d2c543d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -33,6 +33,26 @@
     return sectionEl;
   };
 
+  GrDiffBuilderUnified.prototype.addColumns = function(outputEl, fontSize) {
+    var width = fontSize * 4;
+    var colgroup = document.createElement('colgroup');
+
+    // Add left-side line number.
+    var col = document.createElement('col');
+    col.setAttribute('width', width);
+    colgroup.appendChild(col);
+
+    // Add right-side line number.
+    col = document.createElement('col');
+    col.setAttribute('width', width);
+    colgroup.appendChild(col);
+
+    // Add the content.
+    colgroup.appendChild(document.createElement('col'));
+
+    outputEl.appendChild(colgroup);
+  };
+
   GrDiffBuilderUnified.prototype._createRow = function(section, line) {
     var row = this._createElement('tr', line.type);
     var lineEl = this._createLineEl(line, line.beforeNumber,
@@ -62,7 +82,6 @@
   GrDiffBuilderUnified.prototype._getNextContentOnSide = function(
       content, side) {
     var tr = content.parentElement.parentElement;
-    var content;
     while (tr = tr.nextSibling) {
       if (tr.classList.contains('both') || (
           (side === 'left' && tr.classList.contains('remove')) ||
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index ec19a2d..c944db4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -14,10 +14,12 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
 <link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
 <link rel="import" href="../gr-ranged-comment-layer/gr-ranged-comment-layer.html">
 <link rel="import" href="../gr-syntax-layer/gr-syntax-layer.html">
+
 <dom-module id="gr-diff-builder">
   <template>
     <div class="contentWrapper">
@@ -32,6 +34,7 @@
     <gr-diff-processor
         id="processor"
         groups="{{_groups}}"></gr-diff-processor>
+    <gr-reporting id="reporting"></gr-reporting>
   </template>
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff/gr-diff-group.js"></script>
@@ -55,10 +58,20 @@
         SYNTAX: 'Diff Syntax Render',
       };
 
+      // If any line of the diff is more than the character limit, then disable
+      // syntax highlighting for the entire file.
+      var SYNTAX_MAX_LINE_LENGTH = 500;
+
       Polymer({
         is: 'gr-diff-builder',
 
         /**
+         * Fired when the diff begins rendering.
+         *
+         * @event render-start
+         */
+
+        /**
          * Fired when the diff is rendered.
          *
          * @event render
@@ -74,6 +87,7 @@
           _builder: Object,
           _groups: Array,
           _layers: Array,
+          _showTabs: Boolean,
         },
 
         get diffElement() {
@@ -89,6 +103,7 @@
           this._layers = [
             this.$.syntaxLayer,
             this._createIntralineLayer(),
+            this._createTabIndicatorLayer(),
             this.$.rangeLayer,
           ];
 
@@ -99,6 +114,7 @@
 
         render: function(comments, prefs) {
           this.$.syntaxLayer.enabled = prefs.syntax_highlighting;
+          this._showTabs = !!prefs.show_tabs;
 
           // Stop the processor (if it's running).
           this.$.processor.cancel();
@@ -110,18 +126,27 @@
           this.$.processor.keyLocations = this._getCommentLocations(comments);
 
           this._clearDiffContent();
+          this._builder.addColumns(this.diffElement, prefs.font_size);
 
-          console.time(TimingLabel.TOTAL);
-          console.time(TimingLabel.CONTENT);
+          var reporting = this.$.reporting;
+
+          reporting.time(TimingLabel.TOTAL);
+          reporting.time(TimingLabel.CONTENT);
+          this.fire('render-start');
           return this.$.processor.process(this.diff.content).then(function() {
             if (this.isImageDiff) {
               this._builder.renderDiffImages();
             }
-            console.timeEnd(TimingLabel.CONTENT);
-            console.time(TimingLabel.SYNTAX);
+
+            if (this._anyLineTooLong()) {
+              this.$.syntaxLayer.enabled = false;
+            }
+
+            reporting.timeEnd(TimingLabel.CONTENT);
+            reporting.time(TimingLabel.SYNTAX);
             this.$.syntaxLayer.process().then(function() {
-              console.timeEnd(TimingLabel.SYNTAX);
-              console.timeEnd(TimingLabel.TOTAL);
+              reporting.timeEnd(TimingLabel.SYNTAX);
+              reporting.timeEnd(TimingLabel.TOTAL);
             });
             this.fire('render');
           }.bind(this));
@@ -325,6 +350,31 @@
           };
         },
 
+        _createTabIndicatorLayer: function() {
+          var show = (function() { return this._showTabs; }).bind(this);
+          return {
+            addListener: function() {},
+            annotate: function(el, line) {
+              // If visible tabs are disabled, do nothing.
+              if (!show()) { return; }
+
+              // Find and annotate the locations of tabs.
+              var split = line.text.split('\t');
+              if (!split) { return; }
+              for (var i = 0, pos = 0; i < split.length - 1; i++) {
+                // Skip forward by the length of the content
+                pos += split[i].length;
+
+                GrAnnotation.annotateElement(el, pos, 1,
+                    'style-scope gr-diff tab-indicator');
+
+                // Skip forward by one tab character.
+                pos++;
+              }
+            },
+          };
+        },
+
         /**
          * In pages with large diffs, creating the first comment thread can be
          * slow because nested Polymer elements (particularly
@@ -343,6 +393,18 @@
           Polymer.dom.flush();
           parent.removeChild(thread);
         },
+
+        /**
+         * @return {Boolean} whether any of the lines in _groups are longer
+         * than SYNTAX_MAX_LINE_LENGTH.
+         */
+        _anyLineTooLong: function() {
+          return this._groups.reduce(function(acc, group) {
+            return acc || group.lines.reduce(function(acc, line) {
+              return acc || line.text.length >= SYNTAX_MAX_LINE_LENGTH;
+            }, false);
+          }, false);
+        },
       });
     })();
   </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index 2090e98..d987972 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -65,7 +65,20 @@
 
   var PARTIAL_CONTEXT_AMOUNT = 10;
 
-  GrDiffBuilder.prototype.buildSectionElement = function(group) {
+  /**
+   * Abstract method
+   * @param {string} outputEl
+   * @param {number} fontSize
+   */
+  GrDiffBuilder.prototype.addColumns = function() {
+    throw Error('Subclasses must implement addColumns');
+  };
+
+  /**
+   * Abstract method
+   * @param {Object} group
+   */
+  GrDiffBuilder.prototype.buildSectionElement = function() {
     throw Error('Subclasses must implement buildGroupElement');
   };
 
@@ -163,8 +176,8 @@
   };
 
   /**
-   * Re-renders the DIV.contentText alement for the given side and range of diff
-   * content.
+   * Re-renders the DIV.contentText elements for the given side and range of
+   * diff content.
    */
   GrDiffBuilder.prototype._renderContentByRange = function(start, end, side) {
     var lines = [];
@@ -340,6 +353,9 @@
         side,
         this._comments.meta.projectConfig);
     threadEl.comments = comments;
+    if (opt_side) {
+      threadEl.setAttribute('data-side', opt_side);
+    }
     return threadEl;
   };
 
@@ -363,15 +379,19 @@
 
   GrDiffBuilder.prototype._createTextEl = function(line, opt_side) {
     var td = this._createElement('td');
+    var text = line.text;
     if (line.type !== GrDiffLine.Type.BLANK) {
       td.classList.add('content');
+      if (!text) {
+        text = '\xa0';
+      }
     }
     td.classList.add(line.type);
-    var text = line.text;
     var html = util.escapeHTML(text);
     html = this._addTabWrappers(html, this._prefs.tab_size);
 
-    if (this._textLength(text, this._prefs.tab_size) >
+    if (!this._prefs.line_wrapping &&
+        this._textLength(text, this._prefs.tab_size) >
         this._prefs.line_length) {
       html = this._addNewlines(text, html);
     }
@@ -493,7 +513,7 @@
     for (var i = 0; i < split.length - 1; i++) {
       offset += split[i].length;
       width = tabSize - (offset % tabSize);
-      result += split[i] + this._getTabWrapper(width, this._prefs.show_tabs);
+      result += split[i] + this._getTabWrapper(width);
       offset += width;
     }
     if (split.length) {
@@ -503,7 +523,7 @@
     return result;
   };
 
-  GrDiffBuilder.prototype._getTabWrapper = function(tabSize, showTabs) {
+  GrDiffBuilder.prototype._getTabWrapper = function(tabSize) {
     // Force this to be a number to prevent arbitrary injection.
     tabSize = +tabSize;
     if (isNaN(tabSize)) {
@@ -511,9 +531,6 @@
     }
 
     var str = '<span class="style-scope gr-diff tab ';
-    if (showTabs) {
-      str += 'withIndicator';
-    }
     str += '" style="';
     // TODO(andybons): CSS tab-size is not supported in IE.
     str += 'tab-size:' + tabSize + ';';
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index e8b1453..ad5173d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -57,6 +57,9 @@
     var builder;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
       var prefs = {
         line_length: 10,
         show_tabs: true,
@@ -121,6 +124,36 @@
           '6789');
     });
 
+    test('_addNewlines not called if line_wrapping is true', function(done) {
+      builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
+      var text = (new Array(52)).join('a');
+
+      var line = {text: text, highlights: []};
+      var newLineStub = sinon.stub(builder, '_addNewlines');
+      builder._createTextEl(line);
+      flush(function() {
+        assert.isFalse(newLineStub.called);
+        newLineStub.restore();
+        done();
+      });
+    });
+
+    test('_addNewlines called if line_wrapping is true and meets other ' +
+        'conditions', function(done) {
+      builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
+      var text = (new Array(52)).join('a');
+
+      var line = {text: text, highlights: []};
+      var newLineStub = sinon.stub(builder, '_addNewlines');
+      builder._createTextEl(line);
+
+      flush(function() {
+        assert.isTrue(newLineStub.called);
+        newLineStub.restore();
+        done();
+      });
+    });
+
     test('text length with tabs and unicode', function() {
       assert.equal(builder._textLength('12345', 4), 5);
       assert.equal(builder._textLength('\t\t12', 4), 10);
@@ -414,16 +447,130 @@
       });
     });
 
+    suite('tab indicators', function() {
+      var sandbox;
+      var element;
+      var layer;
+
+      setup(function() {
+        sandbox = sinon.sandbox.create();
+        element = fixture('basic');
+        element._showTabs = true;
+        layer = element._createTabIndicatorLayer();
+      });
+
+      teardown(function() {
+        sandbox.restore();
+      });
+
+      test('does nothing with empty line', function() {
+        var line = {text: ''};
+        var el = document.createElement('div');
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('does nothing with no tabs', function() {
+        var str = 'lorem ipsum no tabs';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('annotates tab at beginning', function() {
+        var str = '\tlorem upsum';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.equal(annotateElementStub.callCount, 1);
+        var args = annotateElementStub.getCalls()[0].args;
+        assert.equal(args[0], el);
+        assert.equal(args[1], 0, 'offset of tab indicator');
+        assert.equal(args[2], 1, 'length of tab indicator');
+        assert.include(args[3], 'tab-indicator');
+      });
+
+      test('does not annotate when disabled', function() {
+        element._showTabs = false;
+
+        var str = '\tlorem upsum';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('annotates multiple in beginning', function() {
+        var str = '\t\tlorem upsum';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.equal(annotateElementStub.callCount, 2);
+
+        var args = annotateElementStub.getCalls()[0].args;
+        assert.equal(args[0], el);
+        assert.equal(args[1], 0, 'offset of tab indicator');
+        assert.equal(args[2], 1, 'length of tab indicator');
+        assert.include(args[3], 'tab-indicator');
+
+        args = annotateElementStub.getCalls()[1].args;
+        assert.equal(args[0], el);
+        assert.equal(args[1], 1, 'offset of tab indicator');
+        assert.equal(args[2], 1, 'length of tab indicator');
+        assert.include(args[3], 'tab-indicator');
+      });
+
+      test('annotates intermediate tabs', function() {
+        var str = 'lorem\tupsum';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.equal(annotateElementStub.callCount, 1);
+        var args = annotateElementStub.getCalls()[0].args;
+        assert.equal(args[0], el);
+        assert.equal(args[1], 5, 'offset of tab indicator');
+        assert.equal(args[2], 1, 'length of tab indicator');
+        assert.include(args[3], 'tab-indicator');
+      });
+    });
+
     suite('rendering', function() {
       var content;
       var outputEl;
+      var sandbox;
 
       setup(function(done) {
+        sandbox = sinon.sandbox.create();
         var prefs = {
           line_length: 10,
           show_tabs: true,
           tab_size: 4,
-          context: -1
+          context: -1,
+          syntax_highlighting: true,
         };
         content = [
           {
@@ -437,14 +584,16 @@
             ]
           },
         ];
+        stub('gr-reporting', {
+          time: sandbox.stub(),
+          timeEnd: sandbox.stub(),
+        });
         element = fixture('basic');
         outputEl = element.queryEffectiveChildren('#diffTable');
-        element.addEventListener('render', function() {
-          done();
-        });
-        sinon.stub(element, '_getDiffBuilder', function() {
+        sandbox.stub(element, '_getDiffBuilder', function() {
           var builder = new GrDiffBuilder(
               {content: content}, {left: [], right: []}, prefs, outputEl);
+          sandbox.stub(builder, 'addColumns');
           builder.buildSectionElement = function(group) {
             var section = document.createElement('stub');
             section.textContent = group.lines.reduce(function(acc, line) {
@@ -455,7 +604,23 @@
           return builder;
         });
         element.diff = {content: content};
-        element.render({left: [], right: []}, prefs);
+        element.render({left: [], right: []}, prefs).then(done);
+      });
+
+      teardown(function() {
+        sandbox.restore();
+      });
+
+      test('reporting', function(done) {
+        var timeStub = element.$.reporting.time;
+        var timeEndStub = element.$.reporting.timeEnd;
+        assert.isTrue(timeStub.calledWithExactly('Diff Total Render'));
+        assert.isTrue(timeStub.calledWithExactly('Diff Content Render'));
+        assert.isTrue(timeStub.calledWithExactly('Diff Syntax Render'));
+        assert.isTrue(timeEndStub.calledWithExactly('Diff Total Render'));
+        assert.isTrue(timeEndStub.calledWithExactly('Diff Content Render'));
+        assert.isTrue(timeEndStub.calledWithExactly('Diff Syntax Render'));
+        done();
       });
 
       test('renderSection', function() {
@@ -467,6 +632,11 @@
         assert.equal(section.innerHTML, prevInnerHTML);
       });
 
+      test('addColumns is called', function(done) {
+        element.render({left: [], right: []}, {}).then(done);
+        assert.isTrue(element._builder.addColumns.called);
+      });
+
       test('getSectionsByLineRange one line', function() {
         var section = outputEl.querySelector('stub:nth-of-type(2)');
         var sections = element._builder.getSectionsByLineRange(1, 1, 'left');
@@ -484,6 +654,36 @@
         assert.strictEqual(sections[0], section[0]);
         assert.strictEqual(sections[1], section[1]);
       });
+
+      test('render-start and render are fired', function(done) {
+        var fireStub = sinon.stub(element, 'fire');
+        element.render({left: [], right: []}, {}).then(function() {
+          assert.isTrue(fireStub.calledWithExactly('render-start'));
+          assert.isTrue(fireStub.calledWithExactly('render'));
+          done();
+        });
+      });
+
+      test('rendering normal-sized diff does not disable syntax', function() {
+        assert.isTrue(element.$.syntaxLayer.enabled);
+      });
+
+      test('rendering large diff disables syntax', function(done) {
+        // Before it renders, set the first diff line to 500 '*' characters.
+        element.diff.content[0].a = [new Array(501).join('*')];
+        element.addEventListener('render', function() {
+          assert.isFalse(element.$.syntaxLayer.enabled);
+          done();
+        });
+        var prefs = {
+          line_length: 10,
+          show_tabs: true,
+          tab_size: 4,
+          context: -1,
+          syntax_highlighting: true,
+        };
+        element.render({left: [], right: []}, prefs);
+      });
     });
 
     suite('mock-diff', function() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
index 25237b5..d0a49dd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
@@ -22,8 +22,7 @@
   <template>
     <style>
       :host {
-        border: 1px solid #ddd;
-        border-right: none;
+        border: 1px solid #bbb;
         display: block;
         white-space: normal;
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
index 305c36a..90a4be1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -29,6 +29,10 @@
         type: Array,
         value: function() { return []; },
       },
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
       patchNum: String,
       path: String,
       projectConfig: Object,
@@ -41,6 +45,10 @@
       _orderedComments: Array,
     },
 
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
     listeners: {
       'comment-update': '_handleCommentUpdate',
     },
@@ -80,6 +88,22 @@
       this._orderedComments = this._sortedComments(this.comments);
     },
 
+    _handleKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (e.keyCode === 69) { // 'e'
+        e.preventDefault();
+        this._expandCollapseComments(e.shiftKey);
+      }
+    },
+
+    _expandCollapseComments: function(actionIsCollapse) {
+      var comments =
+          Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
+      comments.forEach(function(comment) {
+        comment.collapsed = actionIsCollapse;
+      });
+    },
+
     _sortedComments: function(comments) {
       comments.sort(function(c1, c2) {
         var c1Date = c1.__date || util.parseDate(c1.updated);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index 641dc0f..eb87f56 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -54,30 +54,25 @@
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
           updated: '2015-12-25 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_confession',
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sally_to_dr_finklestein',
           message: 'i’m running away',
           updated: '2015-10-31 09:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_defiance',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'i will poison you so i can get away',
           updated: '2015-10-31 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'dr_finklesteins_response',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'no i will pull a thread and your arm will fall off',
           updated: '2015-10-31 11:00:20.396000000'
-        },
-        {
+        }, {
           id: 'sallys_mission',
           message: 'i have to find santa',
           updated: '2015-12-24 21:00:20.396000000'
@@ -89,31 +84,26 @@
           id: 'sally_to_dr_finklestein',
           message: 'i’m running away',
           updated: '2015-10-31 09:00:20.396000000',
-        },
-        {
+        }, {
           id: 'dr_finklesteins_response',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'no i will pull a thread and your arm will fall off',
           updated: '2015-10-31 11:00:20.396000000'
-        },
-        {
+        }, {
           id: 'sallys_defiance',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'i will poison you so i can get away',
           updated: '2015-10-31 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_confession',
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'jacks_reply',
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
           updated: '2015-12-25 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_mission',
           message: 'i have to find santa',
           updated: '2015-12-24 21:00:20.396000000'
@@ -247,20 +237,17 @@
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
           updated: '2015-12-25 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_confession',
           in_reply_to: 'nonexistent_comment',
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sally_to_dr_finklestein',
           in_reply_to: 'nonexistent_comment',
           message: 'i’m running away',
           updated: '2015-10-31 09:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_defiance',
           message: 'i will poison you so i can get away',
           updated: '2015-10-31 15:00:20.396000000',
@@ -268,5 +255,37 @@
       element.comments = comments;
       assert.equal(4, element._orderedComments.length);
     });
+
+    test('keyboard shortcuts', function() {
+      var comments = [
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+        }, {
+          id: 'sallys_confession',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sally_to_dr_finklestein',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }];
+      element.comments = comments;
+      var expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
+      MockInteractions.pressAndReleaseKeyOn(element, 69);  // 'e'
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
+
+      MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift');  // 'e'
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
+      expandCollapseStub.restore();
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
index c3b6233..ebb1f87 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -44,15 +44,20 @@
         padding: .5em .7em;
       }
       .header {
+        cursor: pointer;
         display: flex;
-        padding-bottom: 0;
         font-family: 'Open Sans', sans-serif;
+        padding-bottom: 0;
       }
-      .headerLeft {
+      .headerMiddle {
+        color: #666;
         flex: 1;
+        overflow: hidden;
       }
       .authorName,
       .draftLabel {
+        display: block;
+        float: left;
         font-weight: bold;
       }
       .draftLabel {
@@ -62,6 +67,7 @@
       .date {
         justify-content: flex-end;
         margin-left: 5px;
+        white-space: nowrap;
       }
       a.date:link,
       a.date:visited {
@@ -113,19 +119,62 @@
         background-color: #fff;
         display: block;
       }
+      .show-hide {
+        margin-left: .4em;
+      }
+      input.show-hide {
+        display: none;
+      }
+      label.show-hide {
+        color: #000;
+        cursor: pointer;
+        display: block;
+        font-size: .8em;
+        height: 1.1em;
+        margin-top: .1em;
+      }
+      #container .collapsedContent {
+        display: none;
+      }
+      #container.collapsed {
+        padding-bottom: 3px;
+      }
+      #container.collapsed .collapsedContent {
+        display: block;
+        overflow: hidden;
+        padding-left: 5px;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      #container.collapsed .actions,
+      #container.collapsed gr-linked-text,
+      #container.collapsed iron-autogrow-textarea {
+        display: none;
+      }
     </style>
     <div id="container"
         class="container"
         on-mouseenter="_handleMouseEnter"
         on-mouseleave="_handleMouseLeave">
-      <div class="header" id="header">
+      <div class="header" id="header" on-click="_handleToggleCollapsed">
         <div class="headerLeft">
           <span class="authorName">[[comment.author.name]]</span>
           <span class="draftLabel">DRAFT</span>
         </div>
+        <div class="headerMiddle">
+          <span class="collapsedContent">[[comment.message]]</span>
+        </div>
         <a class="date" href$="[[_computeLinkToComment(comment)]]" on-tap="_handleLinkTap">
           <gr-date-formatter date-str="[[comment.updated]]"></gr-date-formatter>
         </a>
+        <div class="show-hide">
+          <label class="show-hide">
+            <input type="checkbox" class="show-hide"
+               checked$="[[collapsed]]"
+               on-change="_handleToggleCollapsed">
+            [[_computeShowHideText(collapsed)]]
+          </label>
+        </div>
       </div>
       <iron-autogrow-textarea
           id="editTextarea"
@@ -137,6 +186,7 @@
       <gr-linked-text class="message"
           pre
           content="[[comment.message]]"
+          collapsed="[[collapsed]]"
           config="[[projectConfig.commentlinks]]"></gr-linked-text>
       <div class="actions" hidden$="[[!showActions]]">
         <gr-button class="action reply" on-tap="_handleReply">Reply</gr-button>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index 1b30bde..410a813 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -81,6 +81,11 @@
       },
       patchNum: String,
       showActions: Boolean,
+      collapsed: {
+        type: Boolean,
+        value: true,
+        observer: '_toggleCollapseClass',
+      },
       projectConfig: Object,
 
       _xhrPromise: Object,  // Used for testing.
@@ -96,10 +101,20 @@
       '_loadLocalDraft(changeNum, patchNum, comment)',
     ],
 
+    attached: function() {
+      if (this.editing) {
+        this.collapsed = false;
+      }
+    },
+
     detached: function() {
       this.cancelDebouncer('fire-update');
     },
 
+    _computeShowHideText: function(collapsed) {
+      return collapsed ? 'â—€' : 'â–¼';
+    },
+
     save: function() {
       this.comment.message = this._messageText;
       this.disabled = true;
@@ -198,8 +213,29 @@
     },
 
     _handleTextareaKeydown: function(e) {
-      if (e.keyCode == 27) {  // 'esc'
-        this._handleCancel(e);
+      switch (e.keyCode) {
+        case 27: // 'esc'
+          if (this._messageText.length === 0) {
+            this._handleCancel(e);
+          }
+          break;
+        case 83: // 's'
+          if (e.ctrlKey) {
+            this._handleSave(e);
+          }
+          break;
+      }
+    },
+
+    _handleToggleCollapsed: function() {
+      this.collapsed = !this.collapsed;
+    },
+
+    _toggleCollapseClass: function(collapsed) {
+      if (collapsed) {
+        this.$.container.classList.add('collapsed');
+      } else {
+        this.$.container.classList.remove('collapsed');
       }
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index fcf8b41..0ad3e11 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -39,8 +39,15 @@
 </test-fixture>
 
 <script>
+
+  function isVisible(el) {
+    assert.ok(el);
+    return getComputedStyle(el).getPropertyValue('display') !== 'none';
+  }
+
   suite('gr-diff-comment tests', function() {
     var element;
+    var sandbox;
     setup(function() {
       stub('gr-rest-api-interface', {
         getAccount: function() { return Promise.resolve(null); },
@@ -56,6 +63,39 @@
         message: 'is this a crossover episode!?',
         updated: '2015-12-08 19:48:33.843000000',
       };
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('collapsible comments', function() {
+      // When a comment (not draft) is loaded, it should be collapsed
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+
+      // The header middle content is only visible when comments are collapsed.
+      // It shows the message in a condensed way, and limits to a single line.
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      // When the header row is clicked, the comment should expand
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
     });
 
     test('proper event fires on reply', function(done) {
@@ -92,6 +132,45 @@
           'Should navigate to ' + dest + ' without triggering nav');
       showStub.restore();
     });
+
+    test('comment expand and collapse', function() {
+      element.collapsed = true;
+      assert.isFalse(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      element.collapsed = false;
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is is not visible');
+    });
+
+    test('esc does not close comment unless text is empty', function(done) {
+      element.editing = true;
+      element._messageText = 'test';
+      var textarea = element.$.editTextarea;
+      var closeSpy = sandbox.spy(element, '_handleCancel');
+
+      flush(function() {
+        MockInteractions.pressAndReleaseKeyOn(textarea, 27); // esc
+        assert.isFalse(closeSpy.called);
+        element._messageText = '';
+        MockInteractions.pressAndReleaseKeyOn(textarea, 27); // esc
+        assert.isTrue(closeSpy.called);
+        done();
+      });
+    });
   });
 
   suite('gr-diff-comment draft tests', function() {
@@ -135,11 +214,6 @@
       };
     });
 
-    function isVisible(el) {
-      assert.ok(el);
-      return getComputedStyle(el).getPropertyValue('display') != 'none';
-    }
-
     test('button visibility states', function() {
       element.showActions = false;
       assert.isTrue(element.$$('.actions').hasAttribute('hidden'));
@@ -181,6 +255,67 @@
       assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
     });
 
+    test('collapsible drafts', function() {
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is is not visible');
+
+      // When the edit button is pressed, should still see the actions
+      // and also textarea
+      MockInteractions.tap(element.$$('.edit'));
+      assert.isFalse(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is not visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
+
+      // When toggle again, everything should be hidden except for textarea
+      // and header middle content should be visible
+      MockInteractions.tap(element.$.header);
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      // When toggle again, textarea should remain open in the state it was
+      // before
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is not visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
+    });
+
     test('draft creation/cancelation', function(done) {
       assert.isFalse(element.editing);
       MockInteractions.tap(element.$$('.edit'));
@@ -208,9 +343,22 @@
       MockInteractions.tap(element.$$('.cancel'));
       MockInteractions.tap(element.$$('.discard'));
       element.flushDebouncer('fire-update');
+      element._messageText = '';
       MockInteractions.pressAndReleaseKeyOn(element.$.editTextarea, 27); // esc
     });
 
+    test('ctrl+s saves comment', function(done) {
+      var stub = sinon.stub(element, 'save', function() {
+        assert.isTrue(stub.called);
+        stub.restore();
+        done();
+      });
+      element._messageText = 'is that the horse from horsing around??';
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.editTextarea.textarea,
+        83, 'ctrl');  // 'ctrl + s'
+    });
+
     test('draft saving/editing', function(done) {
       var fireStub = sinon.stub(element, 'fire');
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
index 5a41709..c8eea3c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
@@ -21,9 +21,8 @@
   <template>
     <gr-cursor-manager
         id="cursorManager"
-        scroll="keep-visible"
+        scroll="[[_scrollBehavior]]"
         cursor-target-class="target-row"
-        fold-offset-top="[[foldOffsetTop]]"
         target="{{diffRow}}"></gr-cursor-manager>
   </template>
   <script src="gr-diff-cursor.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index 99a0b5c..e783658 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -24,6 +24,11 @@
     UNIFIED: 'UNIFIED_DIFF',
   };
 
+  var ScrollBehavior = {
+    KEEP_VISIBLE: 'keep-visible',
+    NEVER: 'never',
+  };
+
   var LEFT_SIDE_CLASS = 'target-side-left';
   var RIGHT_SIDE_CLASS = 'target-side-right';
 
@@ -54,11 +59,6 @@
         },
       },
 
-      foldOffsetTop: {
-        type: Number,
-        value: 0,
-      },
-
       /**
        * If set, the cursor will attempt to move to the line number (instead of
        * the first chunk) the next time the diff renders. It is set back to null
@@ -68,6 +68,18 @@
         type: Number,
         value: null,
       },
+
+      /**
+       * The scroll behavior for the cursor. Values are 'never' and
+       * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+       * the viewport.
+       */
+      _scrollBehavior: {
+        type: String,
+        value: ScrollBehavior.KEEP_VISIBLE,
+      },
+
+      _listeningForScroll: Boolean,
     },
 
     observers: [
@@ -75,6 +87,15 @@
       '_diffsChanged(diffs.splices)',
     ],
 
+    attached: function() {
+      // Catch when users are scrolling as the view loads.
+      this.listen(window, 'scroll', '_handleWindowScroll');
+    },
+
+    detached: function() {
+      this.unlisten(window, 'scroll', '_handleWindowScroll');
+    },
+
     moveLeft: function() {
       this.side = DiffSides.LEFT;
       if (this._isTargetBlank()) {
@@ -174,12 +195,25 @@
       }
     },
 
+    _handleWindowScroll: function() {
+      if (this._listeningForScroll) {
+        this._scrollBehavior = ScrollBehavior.NEVER;
+        this._listeningForScroll = false;
+      }
+    },
+
     handleDiffUpdate: function() {
       this._updateStops();
 
       if (!this.diffRow) {
         this.reInitCursor();
       }
+      this._scrollBehavior = ScrollBehavior.KEEP_VISIBLE;
+      this._listeningForScroll = false;
+    },
+
+    _handleDiffRenderStart: function() {
+      this._listeningForScroll = true;
     },
 
     /**
@@ -325,12 +359,15 @@
         for (i = splice.index;
             i < splice.index + splice.addedCount;
             i++) {
+          this.listen(this.diffs[i], 'render-start', '_handleDiffRenderStart');
           this.listen(this.diffs[i], 'render', 'handleDiffUpdate');
         }
 
         for (i = 0;
             i < splice.removed && splice.removed.length;
             i++) {
+          this.unlisten(splice.removed[i],
+              'render-start', '_handleDiffRenderStart');
           this.unlisten(splice.removed[i], 'render', 'handleDiffUpdate');
         }
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index 5bdd138..a7d98e6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -98,6 +98,17 @@
       assert.equal(cursorElement.diffRow, firstDeltaRow);
     });
 
+    test('cursor scroll behavior', function() {
+      cursorElement._handleDiffRenderStart();
+      assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+
+      cursorElement._handleWindowScroll();
+      assert.equal(cursorElement._scrollBehavior, 'never');
+
+      cursorElement.handleDiffUpdate();
+      assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+    });
+
     suite('unified diff', function() {
 
       setup(function(done) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
index 54294a1..814a760 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
@@ -37,5 +37,6 @@
     </div>
   </template>
   <script src="gr-annotation.js"></script>
+  <script src="gr-range-normalizer.js"></script>
   <script src="gr-diff-highlight.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index bfe103b..9d7dc2f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -94,6 +94,15 @@
       }
     },
 
+    _normalizeRange: function(range) {
+      range = GrRangeNormalizer.normalize(range);
+      return {
+        start: this._normalizeSelectionSide(range.startContainer,
+            range.startOffset),
+        end: this._normalizeSelectionSide(range.endContainer, range.endOffset),
+      };
+    },
+
     /**
      * Convert DOM Range selection to concrete numbers (line, column, side).
      * Moves range end if it's not inside td.content.
@@ -160,13 +169,12 @@
       if (range.collapsed) {
         return;
       }
-      var start =
-          this._normalizeSelectionSide(range.startContainer, range.startOffset);
+      var normalizedRange = this._normalizeRange(range);
+      var start = normalizedRange.start;
       if (!start) {
         return;
       }
-      var end =
-          this._normalizeSelectionSide(range.endContainer, range.endOffset);
+      var end = normalizedRange.end;
       if (!end) {
         return;
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index 5f84e4f..2f1ded9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -487,6 +487,27 @@
         assert.equal(getActionSide(), 'left');
       });
 
+      test('properly accounts for syntax highlighting', function() {
+        var content = stubContent(140, 'left');
+        var spy = sinon.spy(element, '_normalizeRange');
+        emulateSelection(
+            content.querySelectorAll('hl')[3], 0,
+            content.querySelectorAll('span')[1], 0);
+        var spyCall = spy.getCall(0);
+        var range = window.getSelection().getRangeAt(0);
+        assert.notDeepEqual(spyCall.returnValue, range);
+      });
+
+      test('GrRangeNormalizer._getTextOffset computes text offset', function() {
+        var content = stubContent(140, 'left');
+        var child = content.lastChild.lastChild;
+        var result = GrRangeNormalizer._getTextOffset(content, child);
+        assert.equal(result, 73);
+        content = stubContent(146, 'right');
+        child = content.lastChild;
+        result = GrRangeNormalizer._getTextOffset(content, child);
+        assert.equal(result, 0);
+      });
       // TODO (viktard): Selection starts in line number.
       // TODO (viktard): Empty lines in selection start.
       // TODO (viktard): Empty lines in selection end.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
new file mode 100644
index 0000000..8685d7d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
@@ -0,0 +1,106 @@
+// Copyright (C) 2016 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.
+(function(window) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrRangeNormalizer) { return; }
+
+  // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+  var GrRangeNormalizer = {
+    /**
+     * Remap DOM range to whole lines of a diff if necessary. If the start or
+     * end containers are DOM elements that are singular pieces of syntax
+     * highlighting, the containers are remapped to the .contentText divs that
+     * contain the entire line of code.
+     *
+     * @param {Object} range - the standard DOM selector range.
+     * @return {Object} A modified version of the range that correctly accounts
+     *     for syntax highlighting.
+     */
+    normalize: function(range) {
+      var startContainer = this._getContentTextParent(range.startContainer);
+      var startOffset = range.startOffset + this._getTextOffset(startContainer,
+          range.startContainer);
+      var endContainer = this._getContentTextParent(range.endContainer);
+      var endOffset = range.endOffset + this._getTextOffset(endContainer,
+          range.endContainer);
+      return {
+        startContainer: startContainer,
+        startOffset: startOffset,
+        endContainer: endContainer,
+        endOffset: endOffset,
+      };
+    },
+
+    _getContentTextParent: function(target) {
+      var element = target;
+      if (element.nodeName === '#text') {
+        element = element.parentElement;
+      }
+      while (!element.classList.contains('contentText')) {
+        if (element.parentElement === null) {
+          return target;
+        }
+        element = element.parentElement;
+      }
+      return element;
+    },
+
+    /**
+     * Gets the character offset of the child within the parent.
+     * Performs a synchronous in-order traversal from top to bottom of the node
+     * element, counting the length of the syntax until child is found.
+     *
+     * @param {!Element} The root DOM element to be searched through.
+     * @param {!Element} The child element being searched for.
+     * @return {number}
+     */
+    _getTextOffset: function(node, child) {
+      var count = 0;
+      var stack = [node];
+      while (stack.length) {
+        var n = stack.pop();
+        if (n === child) {
+          break;
+        }
+        if (n.childNodes && n.childNodes.length !== 0) {
+          var arr = [];
+          for (var i = 0; i < n.childNodes.length; i++) {
+            arr.push(n.childNodes[i]);
+          }
+          arr.reverse();
+          stack = stack.concat(arr);
+        } else {
+          count += this._getLength(n);
+        }
+      }
+      return count;
+    },
+
+    /**
+     * The DOM API textContent.length calculation is broken when the text
+     * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+     * @param {Text} A text node.
+     * @return {Number} The length of the text.
+     */
+    _getLength: function(node) {
+      return node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+    },
+  };
+
+  window.GrRangeNormalizer = GrRangeNormalizer;
+})(window);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
index cbf63d6..1188dd3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
@@ -87,7 +87,15 @@
         </select>
       </div>
       <div class="pref">
-        <label for="columnsInput">Columns</label>
+        <label for="lineWrappingInput">Fit to Screen</label>
+        <input
+            is="iron-input"
+            type="checkbox"
+            id="lineWrappingInput"
+            on-tap="_handlelineWrappingTap">
+      </div>
+      <div class="pref" id="columnsPref" hidden$="[[_newPrefs.line_wrapping]]">
+        <label for="columnsInput">Diff Width</label>
         <input is="iron-input" type="number" id="columnsInput"
             prevent-invalid-input
             allowed-pattern="[0-9]"
@@ -100,6 +108,13 @@
             allowed-pattern="[0-9]"
             bind-value="{{_newPrefs.tab_size}}">
       </div>
+      <div class="pref" hidden$="[[!_newPrefs.font_size]]">
+        <label for="fontSizeInput">Font Size</label>
+        <input is="iron-input" type="number" id="fontSizeInput"
+               prevent-invalid-input
+               allowed-pattern="[0-9]"
+               bind-value="{{_newPrefs.font_size}}">
+      </div>
       <div class="pref">
         <label for="showTabsInput">Show tabs</label>
         <input is="iron-input" type="checkbox" id="showTabsInput"
@@ -112,8 +127,8 @@
       </div>
     </div>
     <div class="actions">
-      <gr-button primary on-tap="_handleSave">Save</gr-button>
-      <gr-button on-tap="_handleCancel">Cancel</gr-button>
+      <gr-button id="saveButton" primary on-tap="_handleSave">Save</gr-button>
+      <gr-button id="cancelButton" on-tap="_handleCancel">Cancel</gr-button>
     </div>
   </template>
   <script src="gr-diff-preferences.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
index 4103b2e..2d6bd8c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
@@ -53,6 +53,17 @@
       '_localPrefsChanged(localPrefs.*)',
     ],
 
+    getFocusStops: function() {
+      return {
+        start: this.$.contextSelect,
+        end: this.$.cancelButton,
+      };
+    },
+
+    resetFocus: function() {
+      this.$.contextSelect.focus();
+    },
+
     _prefsChanged: function(changeRecord) {
       var prefs = changeRecord.base;
       // TODO(andybons): This is not supported in IE. Implement a polyfill.
@@ -61,6 +72,7 @@
       this._newPrefs = Object.assign({}, prefs);
       this.$.contextSelect.value = prefs.context;
       this.$.showTabsInput.checked = prefs.show_tabs;
+      this.$.lineWrappingInput.checked = prefs.line_wrapping;
       this.$.syntaxHighlightInput.checked = prefs.syntax_highlighting;
     },
 
@@ -84,6 +96,10 @@
           Polymer.dom(e).rootTarget.checked);
     },
 
+    _handlelineWrappingTap: function(e) {
+      this.set('_newPrefs.line_wrapping', Polymer.dom(e).rootTarget.checked);
+    },
+
     _handleSave: function() {
       this.prefs = this._newPrefs;
       this.localPrefs = this._newLocalPrefs;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
index 0c40d9f..9ec8a02 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
@@ -41,6 +41,7 @@
     test('model changes', function() {
       element.prefs = {
         context: 10,
+        font_size: 12,
         line_length: 100,
         show_tabs: true,
         tab_size: 8,
@@ -51,17 +52,41 @@
       element.$.contextSelect.value = '50';
       element.fire('change', {}, {node: element.$.contextSelect});
       element.$.columnsInput.bindValue = 80;
+      element.$.fontSizeInput.bindValue = 10;
       element.$.tabSizeInput.bindValue = 4;
       MockInteractions.tap(element.$.showTabsInput);
       MockInteractions.tap(element.$.syntaxHighlightInput);
+      MockInteractions.tap(element.$.lineWrappingInput);
 
       assert.equal(element._newPrefs.context, 50);
+      assert.equal(element._newPrefs.font_size, 10);
       assert.equal(element._newPrefs.line_length, 80);
       assert.equal(element._newPrefs.tab_size, 4);
       assert.isFalse(element._newPrefs.show_tabs);
+      assert.isTrue(element._newPrefs.line_wrapping);
       assert.isFalse(element._newPrefs.syntax_highlighting);
     });
 
+    test('clicking fit to screen hides line length input', function() {
+      element.prefs = {line_wrapping: false};
+
+      assert.isFalse(element.$.columnsPref.hidden);
+
+      MockInteractions.tap(element.$.lineWrappingInput);
+      assert.isTrue(element.$.columnsPref.hidden);
+
+      MockInteractions.tap(element.$.lineWrappingInput);
+      assert.isFalse(element.$.columnsPref.hidden);
+    });
+
+    test('clicking save button calls _handleSave function', function() {
+      var savePrefs = sinon.stub(element, '_handleSave');
+      MockInteractions.tap(element.$.saveButton);
+      flushAsynchronousOperations();
+      assert(savePrefs.calledOnce);
+      savePrefs.restore();
+    });
+
     test('events', function(done) {
       var savePromise = new Promise(function(resolve) {
         element.addEventListener('save', function() { resolve(); });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index 2a1e880..d3cc461 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -78,6 +78,23 @@
       },
 
       _nextStepHandle: Number,
+      _isScrolling: Boolean,
+    },
+
+    attached: function() {
+      this.listen(window, 'scroll', '_handleWindowScroll');
+    },
+
+    detached: function() {
+      this.cancel();
+      this.unlisten(window, 'scroll', '_handleWindowScroll');
+    },
+
+    _handleWindowScroll: function() {
+      this._isScrolling = true;
+      this.debounce('resetIsScrolling', function() {
+        this._isScrolling = false;
+      }, 50);
     },
 
     /**
@@ -100,6 +117,11 @@
 
         var currentBatch = 0;
         var nextStep = function() {
+
+          if (this._isScrolling) {
+            this.async(nextStep, 100);
+            return;
+          }
           // If we are done, resolve the promise.
           if (state.sectionIndex >= content.length) {
             resolve(this.groups);
@@ -201,11 +223,11 @@
     /**
      * Take rows of a shared diff section and produce an array of corresponding
      * (potentially collapsed) groups.
-     * @param  {Array<String>} rows
-     * @param  {Number} context
-     * @param  {Number} startLineNumLeft
-     * @param  {Number} startLineNumRight
-     * @param  {String} opt_sectionEnd String representing whether this is the
+     * @param {Array<String>} rows
+     * @param {Number} context
+     * @param {Number} startLineNumLeft
+     * @param {Number} startLineNumRight
+     * @param {String} opt_sectionEnd String representing whether this is the
      *     first section or the last section or neither. Use the values 'first',
      *     'last' and null respectively.
      * @return {Array<GrDiffGroup>}
@@ -236,7 +258,7 @@
       }
 
       // If there is a range to hide.
-      if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 0) {
+      if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 1) {
         var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
         var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
         var linesAfterCtx = lines.slice(hiddenRange[1]);
@@ -264,10 +286,10 @@
     /**
      * Take the rows of a delta diff section and produce the corresponding
      * group.
-     * @param  {Array<String>} rowsAdded
-     * @param  {Array<String>} rowsRemoved
-     * @param  {Number} startLineNumLeft
-     * @param  {Number} startLineNumRight
+     * @param {Array<String>} rowsAdded
+     * @param {Array<String>} rowsRemoved
+     * @param {Number} startLineNumLeft
+     * @param {Number} startLineNumRight
      * @return {GrDiffGroup}
      */
     _deltaGroupFromRows: function(rowsAdded, rowsRemoved, startLineNumLeft,
@@ -325,7 +347,7 @@
      * In order to show comments out of the bounds of the selected context,
      * treat them as separate chunks within the model so that the content (and
      * context surrounding it) renders correctly.
-     * @param  {Object} content The diff content object.
+     * @param {Object} content The diff content object.
      * @return {Object} A new diff content object with regions split up.
      */
     _splitCommonGroupsWithComments: function(content) {
@@ -477,8 +499,8 @@
     /**
      * Given an array and a size, return an array of arrays where no inner array
      * is larger than that size, preserving the original order.
-     * @param  {!Array<T>}
-     * @param  {number}
+     * @param {!Array<T>} array
+     * @param {number} size
      * @return {!Array<!Array<T>>}
      * @template T
      */
@@ -489,7 +511,7 @@
       var head = array.slice(0, array.length - size);
       var tail = array.slice(array.length - size);
 
-      return this._breakdown(head, size).concat([tail])
+      return this._breakdown(head, size).concat([tail]);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
index 9d687ac..f6d0e37 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -40,6 +40,15 @@
         'fugit assum per.';
 
     var element;
+    var sandbox;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
 
     suite('not logged in', function() {
 
@@ -409,6 +418,23 @@
         ]);
       });
 
+      test('scrolling pauses rendering', function() {
+        var contentRow = {
+          ab: [
+            '<!DOCTYPE html>',
+            '<meta charset="utf-8">',
+          ]
+        };
+        var content = _.times(200, _.constant(contentRow));
+        sandbox.stub(element, 'async');
+        element._isScrolling = true;
+        element.process(content);
+        assert.equal(element.groups.length, 1);
+        element._isScrolling = false;
+        element.process(content);
+        assert.equal(element.groups.length, 33);
+      });
+
       suite('gr-diff-processor helpers', function() {
         var rows;
 
@@ -485,6 +511,17 @@
           assert.equal(result[0].lines.length, rows.length);
         });
 
+        test('_sharedGroupsFromRows no single line collapse', function() {
+          rows = rows.slice(0, 7);
+          var context = 3;
+          var result = element._sharedGroupsFromRows(
+              rows, context, 10, 100);
+
+          // Results in one uncollapsed group with all rows.
+          assert.equal(result.length, 1, 'Results in one group');
+          assert.equal(result[0].lines.length, rows.length);
+        });
+
         test('_deltaLinesFromRows', function() {
           var startLineNum = 10;
           var result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
@@ -512,15 +549,6 @@
       });
 
       suite('_breakdown*', function() {
-        var sandbox;
-        setup(function() {
-          sandbox = sinon.sandbox.create();
-        });
-
-        teardown(function() {
-          sandbox.restore();
-        });
-
         test('_breakdownGroup ignores shared groups', function() {
           sandbox.stub(element, '_breakdown');
           var chunk = {ab: ['blah', 'blah', 'blah']};
@@ -574,5 +602,12 @@
         });
       });
     });
+
+    test('detaching cancels', function() {
+      element = fixture('basic');
+      sandbox.stub(element, 'cancel');
+      element.detached();
+      assert(element.cancel.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
index 09cab0b..6a02a2d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
@@ -18,17 +18,21 @@
 <dom-module id="gr-diff-selection">
   <template>
     <style>
-      .contentWrapper ::content .content {
+      .contentWrapper ::content .content,
+      .contentWrapper ::content .contextControl {
         -webkit-user-select: none;
         -moz-user-select: none;
         -ms-user-select: none;
         user-select: none;
       }
 
-      :host.selected-right .contentWrapper ::content .right + .content,
-      :host.selected-left .contentWrapper ::content .left + .content,
-      :host.selected-right .contentWrapper ::content .unified .right ~ .content,
-      :host.selected-left .contentWrapper ::content .unified .left ~ .content {
+      :host-context(.selected-left:not(.selected-comment)) .contentWrapper ::content .side-by-side .left + .content .contentText,
+      :host-context(.selected-right:not(.selected-comment)) .contentWrapper ::content .side-by-side .right + .content .contentText,
+      :host-context(.selected-left:not(.selected-comment)) .contentWrapper ::content .unified .left.lineNum ~ .content:not(.both) .contentText,
+      :host-context(.selected-right:not(.selected-comment)) .contentWrapper ::content .unified .right.lineNum ~ .content .contentText,
+      :host-context(.selected-left.selected-comment) .contentWrapper ::content .side-by-side .left + .content .message,
+      :host-context(.selected-right.selected-comment) .contentWrapper ::content .side-by-side .right + .content .message,
+      :host-context(.selected-comment) .contentWrapper ::content .unified .message {
         -webkit-user-select: text;
         -moz-user-select: text;
         -ms-user-select: text;
@@ -39,5 +43,6 @@
       <content></content>
     </div>
   </template>
+  <script src="../gr-diff-highlight/gr-range-normalizer.js"></script>
   <script src="gr-diff-selection.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index 7d0b7ea..24887e0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -14,11 +14,26 @@
 (function() {
   'use strict';
 
+  /**
+   * Possible CSS classes indicating the state of selection. Dynamically added/
+   * removed based on where the user clicks within the diff.
+   */
+  var SelectionClass = {
+    COMMENT: 'selected-comment',
+    LEFT: 'selected-left',
+    RIGHT: 'selected-right',
+  };
+
   Polymer({
     is: 'gr-diff-selection',
 
     properties: {
+      diff: Object,
       _cachedDiffBuilder: Object,
+      _linesCache: {
+        type: Object,
+        value: function() { return {left: null, right: null}; },
+      },
     },
 
     listeners: {
@@ -27,7 +42,7 @@
     },
 
     attached: function() {
-      this.classList.add('selected-right');
+      this.classList.add(SelectionClass.RIGHT);
     },
 
     get diffBuilder() {
@@ -43,53 +58,191 @@
       if (!lineEl) {
         return;
       }
+      var commentSelected =
+          e.target.parentNode.classList.contains('gr-diff-comment');
       var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var targetClass = 'selected-' + side;
-      var alternateClass = 'selected-' + (side === 'left' ? 'right' : 'left');
+      var targetClasses = [];
+      targetClasses.push(side === 'left' ?
+          SelectionClass.LEFT :
+          SelectionClass.RIGHT);
 
-      if (this.classList.contains(alternateClass)) {
-        this.classList.remove(alternateClass);
+      if (commentSelected) {
+        targetClasses.push(SelectionClass.COMMENT);
       }
-      if (!this.classList.contains(targetClass)) {
-        this.classList.add(targetClass);
+      // Remove any selection classes that do not belong.
+      for (var key in SelectionClass) {
+        if (SelectionClass.hasOwnProperty(key)) {
+          var className = SelectionClass[key];
+          if (targetClasses.indexOf(className) === -1) {
+            this.classList.remove(SelectionClass[key]);
+          }
+        }
+      }
+      // Add new selection classes iff they are not already present.
+      for (var i = 0; i < targetClasses.length; i++) {
+        if (!this.classList.contains(targetClasses[i])) {
+          this.classList.add(targetClasses[i]);
+        }
       }
     },
 
-    _handleCopy: function(e) {
-      if (!e.target.classList.contains('content')) {
-        return;
+    _getCopyEventTarget: function(e) {
+      return Polymer.dom(e).rootTarget;
+    },
+
+    /**
+     * Utility function to determine whether an element is a descendant of
+     * another element with the particular className.
+     *
+     * @param {!Element} element
+     * @param {!string} className
+     * @return {boolean}
+     */
+    _elementDescendedFromClass: function(element, className) {
+      while (!element.classList.contains(className)) {
+        if (!element.parentElement ||
+            element === this.diffBuilder.diffElement) {
+          return false;
+        }
+        element = element.parentElement;
       }
-      var lineEl = this.diffBuilder.getLineElByChild(e.target);
+      return true;
+    },
+
+    _handleCopy: function(e) {
+      var commentSelected = false;
+      var target = this._getCopyEventTarget(e);
+      if (target.type === 'textarea') { return; }
+      if (!this._elementDescendedFromClass(target, 'content')) { return; }
+      if (this.classList.contains(SelectionClass.COMMENT)) {
+        commentSelected = true;
+      }
+      var lineEl = this.diffBuilder.getLineElByChild(target);
       if (!lineEl) {
         return;
       }
       var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var text = this._getSelectedText(side);
-      e.clipboardData.setData('Text', text);
-      e.preventDefault();
+      var text = this._getSelectedText(side, commentSelected);
+      if (text) {
+        e.clipboardData.setData('Text', text);
+        e.preventDefault();
+      }
     },
 
-    _getSelectedText: function(opt_side) {
+    /**
+     * Get the text of the current window selection. If commentSelected is
+     * true, it returns only the text of comments within the selection.
+     * Otherwise it returns the text of the selected diff region.
+     *
+     * @param {!string} The side that is selected.
+     * @param {boolean} Whether or not a comment is selected.
+     * @return {string} The selected text.
+     */
+    _getSelectedText: function(side, commentSelected) {
       var sel = window.getSelection();
       if (sel.rangeCount != 1) {
         return; // No multi-select support yet.
       }
-      var range = sel.getRangeAt(0);
-      var fragment = range.cloneContents();
-      var selector = '.content,td.content:nth-of-type(1)';
-      if (opt_side) {
-        selector = '.' + opt_side + ' + ' + selector;
+      if (commentSelected) {
+        return this._getCommentLines(sel, side);
       }
-      var contentEls = Polymer.dom(fragment).querySelectorAll(selector);
-      if (contentEls.length === 0) {
-        return fragment.textContent;
-      }
+      var range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
+      var startLineEl = this.diffBuilder.getLineElByChild(range.startContainer);
+      var endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+      var startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
+      var endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
 
-      var text = '';
-      for (var i = 0; i < contentEls.length; i++) {
-        text += contentEls[i].textContent + '\n';
+      return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
+          range.endOffset, side);
+    },
+
+    /**
+     * Query the diff object for the selected lines.
+     *
+     * @param {int} startLineNum
+     * @param {int} startOffset
+     * @param {int} endLineNum
+     * @param {int} endOffset
+     * @param {!string} side The side that is currently selected.
+     * @return {string} The selected diff text.
+     */
+    _getRangeFromDiff: function(startLineNum, startOffset, endLineNum,
+        endOffset, side) {
+      var lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+      if (lines.length) {
+        lines[lines.length - 1] = lines[lines.length - 1]
+            .substring(0, endOffset);
+        lines[0] = lines[0].substring(startOffset);
       }
-      return text;
+      return lines.join('\n');
+    },
+
+    /**
+     * Query the diff object for the lines from a particular side.
+     *
+     * @param {!string} side The side that is currently selected.
+     * @return {string[]} An array of strings indexed by line number.
+     */
+    _getDiffLines: function(side) {
+      if (this._linesCache[side]) {
+        return this._linesCache[side];
+      }
+      var lines = [];
+      var chunk;
+      var key = side === 'left' ? 'a' : 'b';
+      for (var chunkIndex = 0;
+          chunkIndex < this.diff.content.length;
+          chunkIndex++) {
+        chunk = this.diff.content[chunkIndex];
+        if (chunk.ab) {
+          lines = lines.concat(chunk.ab);
+        } else if (chunk[key]) {
+          lines = lines.concat(chunk[key]);
+        }
+      }
+      this._linesCache[side] = lines;
+      return lines;
+    },
+
+    /**
+     * Query the diffElement for comments and check whether they lie inside the
+     * selection range.
+     *
+     * @param {!Selection} sel The selection of the window.
+     * @param {!string} side The side that is currently selected.
+     * @return {string} The selected comment text.
+     */
+    _getCommentLines: function(sel, side) {
+      var range = sel.getRangeAt(0);
+      var content = [];
+      // Fall back to default copy behavior if the selection lies within one
+      // comment body.
+      if (range.startContainer === range.endContainer) {
+        return;
+      }
+      if (this._elementDescendedFromClass(range.commonAncestorContainer,
+          'message')) {
+        return;
+      }
+      // Query the diffElement for comments.
+      var messages = this.diffBuilder.diffElement.querySelectorAll(
+          '.side-by-side [data-side="' + side +
+          '"] .message *, .unified .message *');
+
+      for (var i = 0; i < messages.length; i++) {
+        var el = messages[i];
+        // Check if the comment element exists inside the selection.
+        if (sel.containsNode(el, true)) {
+          content.push(el.textContent);
+        }
+      }
+      // Deal with offsets.
+      content[0] = content[0].substring(range.startOffset);
+      if (range.endOffset) {
+        content[content.length - 1] =
+            content[content.length - 1].substring(0, range.endOffset);
+      }
+      return content.join('\n');
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index f99e373..d6a6298 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -27,24 +27,69 @@
 <test-fixture id="basic">
   <template>
     <gr-diff-selection>
-      <table>
+      <table id="diffTable" class="side-by-side">
         <tr>
-          <td class="lineNum left">1</td>
-          <td class="content">ba ba</td>
-          <td class="lineNum right">1</td>
-          <td class="content">some other text</td>
+          <td class="lineNum left" data-value="1">1</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ba ba</div>
+            <div data-side="left">
+              <div class="gr-diff-comment-thread">
+                <div class="message">
+                  <span>This is a comment</span>
+                </div>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="1">1</td>
+          <td class="content">
+            <div class="contentText" data-side="right">some other text</div>
+          </td>
         </tr>
         <tr>
-          <td class="lineNum left">2</td>
-          <td class="content">zin</td>
-          <td class="lineNum right">2</td>
-          <td class="content">more more more</td>
+          <td class="lineNum left" data-value="2">2</td>
+          <td class="content">
+            <div class="contentText" data-side="left">zin</div>
+          </td>
+          <td class="lineNum right" data-value="2">2</td>
+          <td class="content">
+            <div class="contentText" data-side="right">more more more</div>
+            <div data-side="right">
+              <div class="gr-diff-comment-thread">
+                <div class="message">
+                  <span>This is a comment on the right</span>
+                </div>
+              </div>
+            </div>
+          </td>
         </tr>
         <tr>
-          <td class="lineNum left">2</td>
-          <td class="content">ga ga</td>
-          <td class="lineNum right">3</td>
-          <td class="other">some other text</td>
+          <td class="lineNum left" data-value="3">3</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ga ga</div>
+            <div data-side="left">
+              <div class="gr-diff-comment-thread">
+                <div class="message">
+                  <span>This is a different comment</span>
+                </div>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="3">3</td>
+          <td class="other">
+            <div class="contentText" data-side="right">some other text</div>
+          </td>
+        </tr>
+        <tr>
+          <td class="lineNum left" data-value="4">4</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ga ga</div>
+            <div data-side="left">
+              <div class="gr-diff-comment-thread">
+                <textarea data-side="right">test for textarea copying</textarea>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="4">4</td>
         </tr>
       </table>
     </gr-diff-selection>
@@ -54,25 +99,50 @@
 <script>
   suite('gr-diff-selection', function() {
     var element;
+    var sandbox;
 
     var emulateCopyOn = function(target) {
       var fakeEvent = {
         target: target,
-        preventDefault: sinon.stub(),
+        preventDefault: sandbox.stub(),
         clipboardData: {
-          setData: sinon.stub(),
+          setData: sandbox.stub(),
         },
       };
+      element._getCopyEventTarget.returns(target);
       element._handleCopy(fakeEvent);
       return fakeEvent;
     };
 
     setup(function() {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(element, '_getCopyEventTarget');
       element._cachedDiffBuilder = {
-        getLineElByChild: sinon.stub().returns({}),
-        getSideByLineEl: sinon.stub(),
+        getLineElByChild: sandbox.stub().returns({}),
+        getSideByLineEl: sandbox.stub(),
+        diffElement: element.querySelector('#diffTable'),
       };
+      element.diff = {
+        content: [
+          {
+            a: ['ba ba'],
+            b: ['some other text'],
+          },
+          {
+            a: ['zin'],
+            b: ['more more more'],
+          },
+          {
+            a: ['ga ga'],
+            b: ['some other text'],
+          },
+        ],
+      };
+    });
+
+    teardown(function() {
+      sandbox.restore();
     });
 
     test('applies selected-left on left side click', function() {
@@ -97,46 +167,104 @@
     });
 
     test('ignores copy for non-content Element', function() {
-      sinon.stub(element, '_getSelectedText');
+      sandbox.stub(element, '_getSelectedText');
       emulateCopyOn(element.querySelector('.other'));
       assert.isFalse(element._getSelectedText.called);
     });
 
-    test('asks for text for right side Elements', function() {
+    test('asks for text for left side Elements', function() {
       element._cachedDiffBuilder.getSideByLineEl.returns('left');
-      sinon.stub(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('td.content'));
-      assert.deepEqual(['left'], element._getSelectedText.lastCall.args);
+      sandbox.stub(element, '_getSelectedText');
+      emulateCopyOn(element.querySelector('div.contentText'));
+      assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
     });
 
     test('reacts to copy for content Elements', function() {
-      sinon.stub(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('td.content'));
+      sandbox.stub(element, '_getSelectedText');
+      emulateCopyOn(element.querySelector('div.contentText'));
       assert.isTrue(element._getSelectedText.called);
     });
 
     test('copy event is prevented for content Elements', function() {
-      sinon.stub(element, '_getSelectedText');
-      var event = emulateCopyOn(element.querySelector('td.content'));
+      sandbox.stub(element, '_getSelectedText');
+      element._cachedDiffBuilder.getSideByLineEl.returns('left');
+      element._getSelectedText.returns('test');
+      var event = emulateCopyOn(element.querySelector('div.contentText'));
       assert.isTrue(event.preventDefault.called);
     });
 
     test('inserts text into clipboard on copy', function() {
-      sinon.stub(element, '_getSelectedText').returns('the text');
-      var event = emulateCopyOn(element.querySelector('td.content'));
+      sandbox.stub(element, '_getSelectedText').returns('the text');
+      var event = emulateCopyOn(element.querySelector('div.contentText'));
       assert.deepEqual(
           ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
     });
 
     test('copies content correctly', function() {
+      // Fetch the line number.
+      element._cachedDiffBuilder.getLineElByChild = function(child) {
+        while (!child.classList.contains('content') && child.parentElement) {
+          child = child.parentElement;
+        }
+        return child.previousElementSibling;
+      };
+
       element.classList.add('selected-left');
+      element.classList.remove('selected-right');
+
       var selection = window.getSelection();
       var range = document.createRange();
-      range.setStart(element.querySelector('td.content').firstChild, 3);
+      range.setStart(element.querySelector('div.contentText').firstChild, 3);
       range.setEnd(
-          element.querySelectorAll('td.content')[4].firstChild, 2);
+          element.querySelectorAll('div.contentText')[4].firstChild, 2);
       selection.addRange(range);
-      assert.equal('ba\nzin\nga\n', element._getSelectedText('left'));
+      assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
+      selection.removeAllRanges();
+    });
+
+    test('copies comments', function() {
+      element.classList.add('selected-left');
+      element.classList.add('selected-comment');
+      element.classList.remove('selected-right');
+      var selection = window.getSelection();
+      var range = document.createRange();
+      range.setStart(element.querySelector('.message *').firstChild, 3);
+      range.setEnd(
+          element.querySelectorAll('.message *')[2].firstChild, 16);
+      selection.addRange(range);
+      assert.equal('s is a comment\nThis is a differ',
+          element._getSelectedText('left', true));
+      selection.removeAllRanges();
+    });
+
+    test('defers to default behavior for textarea', function() {
+      element.classList.add('selected-left');
+      element.classList.remove('selected-right');
+      var selectedTextSpy = sandbox.spy(element, '_getSelectedText');
+      emulateCopyOn(element.querySelector('textarea'));
+      assert.isFalse(selectedTextSpy.called);
+    });
+
+    test('regression test for 4794', function() {
+      element._cachedDiffBuilder.getLineElByChild = function(child) {
+        while (!child.classList.contains('content') && child.parentElement) {
+          child = child.parentElement;
+        }
+        return child.previousElementSibling;
+      };
+
+      element.classList.add('selected-right');
+      element.classList.remove('selected-left');
+
+      var selection = window.getSelection();
+      var range = document.createRange();
+      range.setStart(
+          element.querySelectorAll('div.contentText')[1].firstChild, 4);
+      range.setEnd(
+          element.querySelectorAll('div.contentText')[1].firstChild, 10);
+      selection.addRange(range);
+      assert.equal(element._getSelectedText('right'), ' other');
+      selection.removeAllRanges();
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 2573ad1..69c8ef5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -16,7 +16,9 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -33,9 +35,22 @@
         background-color: var(--view-background-color);
         display: block;
       }
-      h3 {
+      header,
+      .subHeader {
+        align-items: center;
+        display: flex;
+        justify-content: space-between;
+      }
+      header {
         padding: .75em var(--default-horizontal-margin);
       }
+      .patchRangeLeft {
+        display: flex;
+      }
+      .navLink:not([href]),
+      .downloadLink:not([href]) {
+        color: #999;
+      }
       .reviewed {
         display: inline-block;
         margin: 0 .25em;
@@ -97,10 +112,7 @@
         padding: 0 var(--default-horizontal-margin) 1em;
         color: #666;
       }
-      .header {
-        align-items: center;
-        display: flex;
-        justify-content: space-between;
+      .subHeader {
         margin: 0 var(--default-horizontal-margin) .75em;
       }
       .prefsButton {
@@ -109,6 +121,9 @@
       #modeSelect {
         margin-left: .5em;
       }
+      .separator {
+        margin: 0 .25em;
+      }
       @media screen and (max-width: 50em) {
         .dash {
           display: none;
@@ -123,64 +138,86 @@
           display: block;
           width: 100%;
         }
+        .mobileJumpToFileContainer select {
+          width: 100%;
+        }
       }
     </style>
-    <h3>
-      <a href$="[[_computeChangePath(_changeNum, _patchRange.*, _change.revisions)]]">
-        [[_changeNum]]</a><span>:</span>
-      <span>[[_change.subject]]</span>
-      <span class="dash">—</span>
-      <input id="reviewed"
-          class="reviewed"
-          type="checkbox"
-          on-change="_handleReviewedChange"
-          hidden$="[[!_loggedIn]]" hidden>
-      <div class="jumpToFileContainer">
-        <gr-button link class="dropdown-trigger" id="trigger" on-tap="_showDropdownTapHandler">
-          <span>[[_computeFileDisplayName(_path)]]</span>
-          <span class="downArrow">&#9660;</span>
-        </gr-button>
-        <iron-dropdown id="dropdown" vertical-align="top" vertical-offset="25">
-          <div class="dropdown-content">
+    <header>
+      <h3>
+        <a href$="[[_computeChangePath(_changeNum, _patchRange.*, _change.revisions)]]">
+          [[_changeNum]]</a><span>:</span>
+        <span>[[_change.subject]]</span>
+        <span class="dash">—</span>
+        <input id="reviewed"
+               class="reviewed"
+               type="checkbox"
+               on-change="_handleReviewedChange"
+               hidden$="[[!_loggedIn]]" hidden>
+        <div class="jumpToFileContainer">
+          <gr-button link class="dropdown-trigger" id="trigger" on-tap="_showDropdownTapHandler">
+            <span>[[_computeFileDisplayName(_path)]]</span>
+            <span class="downArrow">&#9660;</span>
+          </gr-button>
+          <iron-dropdown id="dropdown" vertical-align="top" vertical-offset="25">
+            <div class="dropdown-content">
+              <template
+                  is="dom-repeat"
+                  items="[[_fileList]]"
+                  as="path"
+                  initial-count="75">
+                <a href$="[[_computeDiffURL(_changeNum, _patchRange.*, path)]]"
+                   selected$="[[_computeFileSelected(path, _path)]]"
+                   data-key-nav$="[[_computeKeyNav(path, _path, _fileList)]]"
+                   on-tap="_handleFileTap">[[_computeFileDisplayName(path)]]</a>
+              </template>
+            </div>
+          </iron-dropdown>
+        </div>
+        <div class="mobileJumpToFileContainer">
+          <select on-change="_handleMobileSelectChange">
             <template is="dom-repeat" items="[[_fileList]]" as="path">
-              <a href$="[[_computeDiffURL(_changeNum, _patchRange.*, path)]]"
-                 selected$="[[_computeFileSelected(path, _path)]]"
-                 data-key-nav$="[[_computeKeyNav(path, _path, _fileList)]]"
-                 on-tap="_handleFileTap">
-                 [[_computeFileDisplayName(path)]]
-              </a>
+              <option
+                  value$="[[path]]"
+                  selected$="[[_computeFileSelected(path, _path)]]">
+                [[_computeTruncatedFileDisplayName(path)]]
+              </option>
             </template>
-          </div>
-        </iron-dropdown>
+          </select>
+        </div>
+      </h3>
+      <div>
+        <a class="navLink"
+           href$="[[_computeNavLinkURL(_path, _fileList, -1, 1)]]">Prev</a>
+        /
+        <a class="navLink"
+           href$="[[_computeNavLinkURL(_path, _fileList, 1, 1)]]">Next</a>
       </div>
-      <div class="mobileJumpToFileContainer">
-        <select on-change="_handleMobileSelectChange">
-          <template is="dom-repeat" items="[[_fileList]]" as="path">
-            <option
-                value$="[[path]]"
-                selected$="[[_computeFileSelected(path, _path)]]">
-              [[_computeFileDisplayName(path)]]
-            </option>
-          </template>
-        </select>
-      </div>
-    </h3>
+    </header>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
     <div hidden$="[[_loading]]" hidden>
-      <div class="header">
-        <gr-patch-range-select
-            path="[[_path]]"
-            change-num="[[_changeNum]]"
-            patch-range="[[_patchRange]]"
-            files-weblinks="[[_filesWeblinks]]"
-            available-patches="[[_computeAvailablePatches(_change.revisions)]]">
-        </gr-patch-range-select>
+      <div class="subHeader">
+        <div class="patchRangeLeft">
+          <gr-patch-range-select
+              path="[[_path]]"
+              change-num="[[_changeNum]]"
+              patch-range="[[_patchRange]]"
+              files-weblinks="[[_filesWeblinks]]"
+              available-patches="[[_computeAvailablePatches(_change.revisions)]]">
+          </gr-patch-range-select>
+          <span class="separator">/</span>
+          <a class="downloadLink"
+             href$="[[_computeDownloadLink(_changeNum, _patchRange, _path)]]">
+            Download
+          </a>
+        </div>
         <div>
           <select
               id="modeSelect"
               is="gr-select"
               bind-value="{{changeViewState.diffMode}}"
-              hidden$="[[_computeModeSelectHidden(_isImageDiff)]]">
+              hidden$="[[_computeModeSelectHidden(_isImageDiff)]]"
+              on-change="_handleDropdownChange">
             <option value="SIDE_BY_SIDE">Side By Side</option>
             <option value="UNIFIED_DIFF">Unified</option>
           </select>
@@ -195,6 +232,7 @@
       </div>
       <gr-overlay id="prefsOverlay" with-backdrop>
         <gr-diff-preferences
+            id="diffPreferences"
             prefs="{{_prefs}}"
             local-prefs="{{_localPrefs}}"
             on-save="_handlePrefsSave"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index d6a3bc0..1cca110 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -16,6 +16,8 @@
 
   var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
 
+  var MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
+
   var DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
@@ -26,7 +28,7 @@
     RIGHT: 'right',
   };
 
-  var HASH_PATTERN = /^b?\d+$/;
+  var HASH_PATTERN = /^[ab]?\d+$/;
 
   Polymer({
     is: 'gr-diff-view',
@@ -89,10 +91,11 @@
 
     behaviors: [
       Gerrit.KeyboardShortcutBehavior,
+      Gerrit.RESTClientBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     observers: [
-      '_getChangeDetail(_changeNum)',
       '_getProjectConfig(_change.project)',
       '_getFiles(_changeNum, _patchRange.*)',
     ],
@@ -104,6 +107,19 @@
           this._setReviewed(true);
         }
       }.bind(this));
+      if (this.changeViewState.diffMode === null) {
+        // If screen size is small, always default to unified view.
+        if (this._getWindowWidth() < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX) {
+          this.set('changeViewState.diffMode', DiffViewMode.UNIFIED);
+        } else {
+          // Initialize with user's diff mode preference. Default to
+          // SIDE_BY_SIDE in the meantime.
+          this.set('changeViewState.diffMode', DiffViewMode.SIDE_BY_SIDE);
+          this.$.restAPI.getPreferences().then(function(prefs) {
+            this.set('changeViewState.diffMode', prefs.diff_view);
+          }.bind(this));
+        }
+      }
 
       if (this._path) {
         this.fire('title-change',
@@ -113,11 +129,6 @@
       this.$.cursor.push('diffs', this.$.diff);
     },
 
-    detached: function() {
-      // Reset the diff mode to null so that it reverts to the user preference.
-      this.changeViewState.diffMode = null;
-    },
-
     _getLoggedIn: function() {
       return this.$.restAPI.getLoggedIn();
     },
@@ -152,6 +163,10 @@
       return this.$.restAPI.getPreferences();
     },
 
+    _getWindowWidth: function() {
+      return window.innerWidth;
+    },
+
     _handleReviewedChange: function(e) {
       this._setReviewed(Polymer.dom(e).rootTarget.checked);
     },
@@ -170,8 +185,12 @@
           this._patchRange.patchNum, this._path, reviewed);
     },
 
+    _checkForModifiers: function(e) {
+      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || false;
+    },
+
     _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       switch (e.keyCode) {
         case 37: // left
@@ -197,6 +216,7 @@
           this.$.cursor.moveUp();
           break;
         case 67: // 'c'
+          if (this._checkForModifiers(e)) { return; }
           if (!this.$.diff.isRangeSelected()) {
             e.preventDefault();
             var line = this.$.cursor.getTargetLineElement();
@@ -207,11 +227,11 @@
           break;
         case 219:  // '['
           e.preventDefault();
-          this._navToFile(this._fileList, -1);
+          this._navToFile(this._path, this._fileList, -1);
           break;
         case 221:  // ']'
           e.preventDefault();
-          this._navToFile(this._fileList, 1);
+          this._navToFile(this._path, this._fileList, 1);
           break;
         case 78:  // 'n'
           e.preventDefault();
@@ -251,25 +271,55 @@
           break;
         case 188:  // ','
           e.preventDefault();
-          this.$.prefsOverlay.open();
+          this._openPrefs();
           break;
       }
     },
 
-    _navToFile: function(fileList, direction) {
-      if (fileList.length == 0) { return; }
+    _navToFile: function(path, fileList, direction) {
+      var url = this._computeNavLinkURL(path, fileList, direction);
+      if (!url) { return; }
 
-      var idx = fileList.indexOf(this._path) + direction;
+      page.show(this._computeNavLinkURL(path, fileList, direction));
+    },
+
+    _openPrefs: function() {
+      this.$.prefsOverlay.open().then(function() {
+        var diffPreferences = this.$.diffPreferences;
+        var focusStops = diffPreferences.getFocusStops();
+        this.$.prefsOverlay.setFocusStops(focusStops);
+        this.$.diffPreferences.resetFocus();
+      }.bind(this));
+    },
+
+    /**
+     * @param {?string} path The path of the current file being shown.
+     * @param {Array.<string>} fileList The list of files in this change and
+     *     patch range.
+     * @param {number} direction Either 1 (next file) or -1 (prev file).
+     * @param {(number|boolean)} opt_noUp Whether to return to the change view
+     *     when advancing the file goes outside the bounds of fileList.
+     *
+     * @return {?string} The next URL when proceeding in the specified
+     *     direction.
+     */
+    _computeNavLinkURL: function(path, fileList, direction, opt_noUp) {
+      if (!path || fileList.length === 0) { return null; }
+
+      var idx = fileList.indexOf(path);
+      if (idx === -1) { return null; }
+
+      idx += direction;
+      // Redirect to the change view if opt_noUp isn’t truthy and idx falls
+      // outside the bounds of [0, fileList.length).
       if (idx < 0 || idx > fileList.length - 1) {
-        page.show(this._getChangePath(
+        if (opt_noUp) { return null; }
+        return this._getChangePath(
             this._changeNum,
             this._patchRange,
-            this._change && this._change.revisions));
-        return;
+            this._change && this._change.revisions);
       }
-      page.show(this._getDiffURL(this._changeNum,
-                                 this._patchRange,
-                                 fileList[idx]));
+      return this._getDiffURL(this._changeNum, this._patchRange, fileList[idx]);
     },
 
     _paramsChanged: function(value) {
@@ -318,7 +368,7 @@
     _loadHash: function(hash) {
       var hash = hash.replace(/^#/, '');
       if (!HASH_PATTERN.test(hash)) { return; }
-      if (hash[0] === 'b') {
+      if (hash[0] === 'a' || hash[0] === 'b') {
         this.$.cursor.side = DiffSides.LEFT;
         hash = hash.substring(1);
       } else {
@@ -340,7 +390,7 @@
 
     _getDiffURL: function(changeNum, patchRange, path) {
       return '/c/' + changeNum + '/' + this._patchRangeStr(patchRange) + '/' +
-          path;
+          this.encodeURL(path, true);
     },
 
     _computeDiffURL: function(changeNum, patchRangeRecord, path) {
@@ -389,7 +439,22 @@
     },
 
     _computeFileDisplayName: function(path) {
-      return path == COMMIT_MESSAGE_PATH ? 'Commit message' : path;
+      return path === COMMIT_MESSAGE_PATH ? 'Commit message' : path;
+    },
+
+    _computeTruncatedFileDisplayName: function(path) {
+      return path === COMMIT_MESSAGE_PATH ?
+          'Commit message' : this._shortenPath(path);
+    },
+
+    _shortenPath: function(path) {
+      var pathPieces = path.split('/');
+
+      if (pathPieces.length < 2) {
+        return path;
+      }
+      // Character is an ellipsis.
+      return '\u2026/' + pathPieces.pop();
     },
 
     _computeFileSelected: function(path, currentPath) {
@@ -426,7 +491,7 @@
 
     _handlePrefsTap: function(e) {
       e.preventDefault();
-      this.$.prefsOverlay.open();
+      this._openPrefs();
     },
 
     _handlePrefsSave: function(e) {
@@ -482,7 +547,17 @@
 
     _onLineSelected: function(e, detail) {
       this.$.cursor.moveToLineNumber(detail.number, detail.side);
-      history.pushState(null, null, '#' + this.$.cursor.getAddress());
+      history.replaceState(null, null, '#' + this.$.cursor.getAddress());
+    },
+
+    _handleDropdownChange: function(e) {
+      e.target.blur();
+    },
+
+    _computeDownloadLink: function(changeNum, patchRange, path) {
+      var url = this.changeBaseURL(changeNum, patchRange.patchNum);
+      url += '/patch?zip&path=' + encodeURIComponent(path);
+      return url;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index 0a4d6b6..99da821 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -32,11 +32,20 @@
   </template>
 </test-fixture>
 
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
 <script>
   suite('gr-diff-view tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
+
       stub('gr-rest-api-interface', {
         getLoggedIn: function() { return Promise.resolve(false); },
         getProjectConfig: function() { return Promise.resolve({}); },
@@ -47,11 +56,14 @@
       element = fixture('basic');
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('toggle left diff with a hotkey', function() {
-      var toggleLeftDiffStub = sinon.stub(element.$.diff, 'toggleLeftDiff');
+      var toggleLeftDiffStub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
       MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'a'
       assert.isTrue(toggleLeftDiffStub.calledOnce);
-      toggleLeftDiffStub.restore();
     });
 
     test('keyboard shortcuts', function() {
@@ -69,7 +81,7 @@
       element._path = 'glados.txt';
       element.changeViewState.selectedFileIndex = 1;
 
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
       MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
       assert(showStub.lastCall.calledWithExactly('/c/42/'),
           'Should navigate to /c/42/');
@@ -97,32 +109,44 @@
           'Should navigate to /c/42/');
       assert.equal(element.changeViewState.selectedFileIndex, 0);
 
-      var showPrefsStub = sinon.stub(element.$.prefsOverlay, 'open');
+      var showPrefsStub = sandbox.stub(element.$.prefsOverlay, 'open',
+          function() { return Promise.resolve({}); });
+
       MockInteractions.pressAndReleaseKeyOn(element, 188);  // ','
       assert(showPrefsStub.calledOnce);
 
-      var scrollStub = sinon.stub(element.$.cursor, 'moveToNextChunk');
+      var scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 78);  // 'n'
       assert(scrollStub.calledOnce);
-      scrollStub.restore();
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousChunk');
+      scrollStub = sandbox.stub(element.$.cursor, 'moveToPreviousChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 80);  // 'p'
       assert(scrollStub.calledOnce);
-      scrollStub.restore();
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToNextCommentThread');
+      scrollStub = sandbox.stub(element.$.cursor, 'moveToNextCommentThread');
       MockInteractions.pressAndReleaseKeyOn(element, 78, ['shift']);  // 'N'
       assert(scrollStub.calledOnce);
-      scrollStub.restore();
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousCommentThread');
+      scrollStub = sandbox.stub(element.$.cursor,
+          'moveToPreviousCommentThread');
       MockInteractions.pressAndReleaseKeyOn(element, 80, ['shift']);  // 'P'
       assert(scrollStub.calledOnce);
-      scrollStub.restore();
+    });
 
-      showPrefsStub.restore();
-      showStub.restore();
+    test('saving diff preferences', function() {
+      var savePrefs = sandbox.stub(element, '_handlePrefsSave');
+      var cancelPrefs = sandbox.stub(element, '_handlePrefsCancel');
+      element.$.diffPreferences._handleSave();
+      assert(savePrefs.calledOnce);
+      assert(cancelPrefs.notCalled);
+    });
+
+    test('cancelling diff preferences', function() {
+      var savePrefs = sandbox.stub(element, '_handlePrefsSave');
+      var cancelPrefs = sandbox.stub(element, '_handlePrefsCancel');
+      element.$.diffPreferences._handleCancel();
+      assert(cancelPrefs.calledOnce);
+      assert(savePrefs.notCalled);
     });
 
     test('keyboard shortcuts with patch range', function() {
@@ -139,7 +163,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
 
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
 
       MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
@@ -176,8 +200,6 @@
       MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
           'Should navigate to /c/42/5..10');
-
-      showStub.restore();
     });
 
     test('keyboard shortcuts with old patch number', function() {
@@ -195,7 +217,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
 
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
 
       MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
@@ -232,8 +254,6 @@
       MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
-
-      showStub.restore();
     });
 
     test('go up to change via kb without change loaded', function() {
@@ -246,7 +266,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
 
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
 
       MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
@@ -283,8 +303,6 @@
       MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
-
-      showStub.restore();
     });
 
     test('jump to file dropdown', function() {
@@ -338,6 +356,65 @@
       assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/wheatley.md');
     });
 
+    test('prev/next links', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '10',
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      var linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
+      assert.equal(linkEls.length, 2);
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/chell.go');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/10/wheatley.md');
+      element._path = 'wheatley.md';
+      flushAsynchronousOperations();
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/glados.txt');
+      assert.isFalse(linkEls[1].hasAttribute('href'));
+      element._path = 'chell.go';
+      flushAsynchronousOperations();
+      assert.isFalse(linkEls[0].hasAttribute('href'));
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/10/glados.txt');
+    });
+
+    test('download link', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '10',
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      assert.equal(element.$$('.downloadLink').getAttribute('href'),
+          '/changes/42/revisions/10/patch?zip&path=glados.txt');
+    });
+
+    test('prev/next links with patch range', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      var linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
+      assert.equal(linkEls.length, 2);
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/chell.go');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10/wheatley.md');
+      element._path = 'wheatley.md';
+      flushAsynchronousOperations();
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/glados.txt');
+      assert.isFalse(linkEls[1].hasAttribute('href'));
+      element._path = 'chell.go';
+      flushAsynchronousOperations();
+      assert.isFalse(linkEls[0].hasAttribute('href'));
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10/glados.txt');
+    });
+
     test('file review status', function(done) {
       element._loggedIn = true;
       element._changeNum = '42';
@@ -347,7 +424,7 @@
       };
       element._fileList = ['/COMMIT_MSG'];
       element._path = '/COMMIT_MSG';
-      var saveReviewedStub = sinon.stub(element, '_saveReviewedState',
+      var saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
           function() { return Promise.resolve(); });
 
       flush(function() {
@@ -363,7 +440,6 @@
         assert.isTrue(commitMsg.checked);
         assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
 
-        saveReviewedStub.restore();
         done();
       });
     });
@@ -371,7 +447,7 @@
     test('diff mode selector correctly toggles the diff', function() {
       var select = element.$.modeSelect;
       var diffDisplay = element.$.diff;
-
+      var blurSpy = sandbox.spy(select, 'blur');
       element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
 
       // The mode selected in the view state reflects the selected option.
@@ -383,7 +459,6 @@
 
       // We will simulate a user change of the selected mode.
       var newMode = 'UNIFIED_DIFF';
-
       // Set the actual value of the select, and simulate the change event.
       select.value = newMode;
       element.fire('change', {}, {node: select});
@@ -392,6 +467,61 @@
       assert.equal(element._getDiffViewMode(), newMode);
       assert.equal(element._getDiffViewMode(), select.value);
       assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
+      assert(blurSpy.called, 'select should be blurred after selection');
+    });
+
+    test('diff mode selector initializes from preferences', function() {
+      var resolvePrefs;
+      var prefsPromise = new Promise(function(resolve) {
+        resolvePrefs = resolve;
+      });
+      var getPreferencesStub = sandbox.stub(element.$.restAPI, 'getPreferences',
+          function() { return prefsPromise; });
+
+      // Attach a new gr-diff-view so we can intercept the preferences fetch.
+      var view = document.createElement('gr-diff-view');
+      var select = view.$.modeSelect;
+      fixture('blank').appendChild(view);
+      flushAsynchronousOperations();
+
+      // At this point the diff mode doesn't yet have the user's preference.
+      assert.equal(select.value, 'SIDE_BY_SIDE');
+
+      // Receive the overriding preference.
+      resolvePrefs({diff_view: 'UNIFIED'});
+      flushAsynchronousOperations();
+      assert.equal(select.value, 'SIDE_BY_SIDE');
+    });
+
+    test('unified view is always default on small screens', function() {
+      var resolvePrefs;
+      var prefsPromise = new Promise(function(resolve) {
+        resolvePrefs = resolve;
+      });
+
+      var getPreferencesStub = sandbox.stub(element.$.restAPI, 'getPreferences',
+          function() { return prefsPromise; });
+
+      // Attach a new gr-diff-view so we can intercept the preferences fetch.
+      var view = document.createElement('gr-diff-view');
+
+      view.changeViewState = {diffMode: null};
+
+      sandbox.stub(view, '_getWindowWidth', function() { return 800; });
+
+      var select = view.$.modeSelect;
+      fixture('blank').appendChild(view);
+      flushAsynchronousOperations();
+
+      // At this point the diff mode doesn't yet have the user's preference.
+      assert.equal(select.value, 'UNIFIED_DIFF');
+
+      // Receive the overriding preference.
+      resolvePrefs({diff_view: 'SIDE_BY_SIDE'});
+      flushAsynchronousOperations();
+
+      // On small screens, unified should override user perferences
+      assert.equal(select.value, 'UNIFIED_DIFF');
     });
 
     test('_loadHash', function() {
@@ -410,6 +540,63 @@
       element._loadHash('b345');
       assert.equal(element.$.cursor.initialLineNumber, 345);
       assert.equal(element.$.cursor.side, 'left');
+
+      // GWT-style base hash:
+      element._loadHash('a123');
+      assert.equal(element.$.cursor.initialLineNumber, 123);
+      assert.equal(element.$.cursor.side, 'left');
+    });
+
+    test('_checkForModifiers', function() {
+      assert.isTrue(element._checkForModifiers({altKey: true}));
+      assert.isTrue(element._checkForModifiers({ctrlKey: true}));
+      assert.isTrue(element._checkForModifiers({metaKey: true}));
+      assert.isTrue(element._checkForModifiers({shiftKey: true}));
+      assert.isFalse(element._checkForModifiers({}));
+    });
+
+    test('_shortenPath with long path should add ellipsis', function() {
+      var path =
+          'level1/level2/level3/level4/file.js';
+      var shortenedPath = element._shortenPath(path);
+      // The expected path is truncated with an ellipsis.
+      var expectedPath = '\u2026/file.js';
+      assert.equal(shortenedPath, expectedPath);
+
+      var path = 'level2/file.js';
+      var shortenedPath = element._shortenPath(path);
+      assert.equal(shortenedPath, expectedPath);
+    });
+
+    test('_shortenPath with short path should not add ellipsis', function() {
+      var path = 'file.js';
+      var expectedPath = 'file.js';
+      var shortenedPath = element._shortenPath(path);
+      assert.equal(shortenedPath, expectedPath);
+    });
+
+    test('_onLineSelected', function() {
+      var replaceStateStub = sandbox.stub(history, 'replaceState');
+      var moveStub = sandbox.stub(element.$.cursor, 'moveToLineNumber');
+
+      var e = {};
+      var detail = {number: 123, side: 'right'};
+
+      element._onLineSelected(e, detail);
+
+      assert.isTrue(moveStub.called);
+      assert.equal(moveStub.lastCall.args[0], detail.number);
+      assert.equal(moveStub.lastCall.args[1], detail.side);
+
+      assert.isTrue(replaceStateStub.called);
+    });
+
+    test('_getDiffURL encodes special characters', function() {
+      var changeNum = 123;
+      var patchRange = {basePatchNum: 123, patchNum: 456};
+      var path = 'c++/cpp.cpp';
+      assert.equal(element._getDiffURL(changeNum, patchRange, path),
+          '/c/123/123..456/c%252B%252B/cpp.cpp');
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 46612a0..855f45a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -28,9 +28,10 @@
     <style>
       :host {
         --light-remove-highlight-color: #fee;
-        --dark-remove-highlight-color: #ffd4d4;
+        --dark-remove-highlight-color: rgba(255, 0, 0, 0.15);
         --light-add-highlight-color: #efe;
-        --dark-add-highlight-color: #d4ffd4;
+        --dark-add-highlight-color: rgba(0, 255, 0, 0.15);
+
       }
       :host.no-left .sideBySide ::content .left,
       :host.no-left .sideBySide ::content .left + td,
@@ -50,8 +51,8 @@
         border-collapse: collapse;
         border-right: 1px solid #ddd;
         table-layout: fixed;
-      }
-      table tbody {
+
+        /* Hint GPU acceleration */
         -webkit-transform: translateZ(0);
         -moz-transform: translateZ(0);
         -ms-transform: translateZ(0);
@@ -86,18 +87,20 @@
       .content {
         background-color: #fff;
       }
+      .full-width {
+        width: 100%;
+      }
+      .full-width .contentText {
+        white-space: pre-wrap;
+        word-wrap: break-word;
+      }
       .lineNum,
       .content {
+        /* Set font size based the user's diff preference. */
+        font-size: var(--font-size, 12px);
         vertical-align: top;
         white-space: pre;
       }
-      .contentText:empty:before {
-        /**
-         * Insert glyph to prevent empty diff content from collapsing.
-         * "\200B" is a 'ZERO WIDTH SPACE' (U+200B)
-         */
-        content: "\200B";
-      }
       .contextLineNum:before,
       .lineNum:before {
         display: inline-block;
@@ -119,6 +122,7 @@
            allows them to shrink. */
         max-width: var(--content-width, 80ch);
         min-width: var(--content-width, 80ch);
+        width: var(--content-width, 80ch);
       }
       .content.add .intraline,
       .content.add.darkHighlight {
@@ -152,17 +156,17 @@
       }
       .tab {
         display: inline-block;
-        position: relative;
       }
-      .tab.withIndicator {
-        color: #D68E47;
-        text-decoration: line-through;
+      .tab-indicator:before {
+        color: #C62828;
+        /* >> character */
+        content: '\00BB';
       }
     </style>
     <style include="gr-theme-default"></style>
     <div class$="[[_computeContainerClass(_loggedIn, viewMode)]]"
         on-tap="_handleTap">
-      <gr-diff-selection>
+      <gr-diff-selection diff="[[_diff]]">
         <gr-diff-highlight
             id="highlights"
             logged-in="[[_loggedIn]]"
@@ -172,10 +176,11 @@
               comments="[[_comments]]"
               diff="[[_diff]]"
               view-mode="[[viewMode]]"
+              line-wrapping="[[lineWrapping]]"
               is-image-diff="[[isImageDiff]]"
               base-image="[[_baseImage]]"
               revision-image="[[_revisionImage]]">
-            <table id="diffTable"></table>
+            <table id="diffTable" class$="[[_diffTableClass]]"></table>
           </gr-diff-builder>
         </gr-diff-highlight>
       </gr-diff-selection>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index dbcbb38..eb95a77 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -34,6 +34,11 @@
 
     properties: {
       changeNum: String,
+      expanded: {
+        type: Boolean,
+        value: true,
+        observer: '_handleShowDiff',
+      },
       patchRange: Object,
       path: String,
       prefs: {
@@ -61,12 +66,21 @@
         type: Boolean,
         value: false,
       },
+      lineWrapping: {
+        type: Boolean,
+        value: false,
+        observer: '_lineWrappingObserver',
+      },
       viewMode: {
         type: String,
         value: DiffViewMode.SIDE_BY_SIDE,
         observer: '_viewModeObserver',
       },
       _diff: Object,
+      _diffTableClass: {
+        type: String,
+        value: '',
+      },
       _comments: Object,
       _baseImage: Object,
       _revisionImage: Object,
@@ -84,6 +98,13 @@
       this._getLoggedIn().then(function(loggedIn) {
         this._loggedIn = loggedIn;
       }.bind(this));
+
+    },
+
+    ready: function() {
+      if (this._canRender()) {
+        this.reload();
+      }
     },
 
     reload: function() {
@@ -108,7 +129,7 @@
     },
 
     getCursorStops: function() {
-      if (this.hidden) {
+      if (!this.expanded) {
         return [];
       }
 
@@ -141,6 +162,16 @@
       this.toggleClass('no-left');
     },
 
+    _handleShowDiff: function() {
+      if (this._canRender()) {
+        this.reload();
+      }
+    },
+
+    _canRender: function() {
+      return this.changeNum && this.patchRange && this.path && this.expanded;
+    },
+
     _getCommentThreads: function() {
       return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
     },
@@ -334,9 +365,26 @@
       this._prefsChanged(this.prefs);
     },
 
+    _lineWrappingObserver: function() {
+      this._prefsChanged(this.prefs);
+    },
+
     _prefsChanged: function(prefs) {
       if (!prefs) { return; }
-      this.customStyle['--content-width'] = prefs.line_length + 'ch';
+      if (prefs.line_wrapping) {
+        this._diffTableClass = 'full-width';
+        if (this.viewMode === 'SIDE_BY_SIDE') {
+          this.customStyle['--content-width'] = 'none';
+        }
+      } else {
+        this._diffTableClass = '';
+        this.customStyle['--content-width'] = prefs.line_length + 'ch';
+      }
+
+      if (!!prefs.font_size) {
+        this.customStyle['--font-size'] = prefs.font_size + 'px';
+      }
+
       this.updateStyles();
 
       if (this._diff && this._comments) {
@@ -353,6 +401,12 @@
     },
 
     _handleGetDiffError: function(response) {
+      // Loading the diff may respond with 409 if the file is too large. In this
+      // case, use a toast error..
+      if (response.status === 409) {
+        this.fire('server-error', {response: response});
+        return;
+      }
       this.fire('page-error', {response: response});
     },
 
@@ -363,12 +417,12 @@
           this.patchRange.patchNum,
           this.path,
           this._handleGetDiffError.bind(this)).then(function(diff) {
-               this.filesWeblinks = {
-                 meta_a: diff.meta_a && diff.meta_a.web_links,
-                 meta_b: diff.meta_b && diff.meta_b.web_links,
-               };
-               return diff;
-             }.bind(this));
+            this.filesWeblinks = {
+              meta_a: diff && diff.meta_a && diff.meta_a.web_links,
+              meta_b: diff && diff.meta_b && diff.meta_b.web_links,
+            };
+            return diff;
+          }.bind(this));
     },
 
     _getDiffComments: function() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index c33eadb..7e20a5b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -36,7 +36,6 @@
     var element;
 
     suite('not logged in', function() {
-
       setup(function() {
         stub('gr-rest-api-interface', {
           getLoggedIn: function() { return Promise.resolve(false); },
@@ -327,13 +326,25 @@
         });
         content.click();
       });
+
+      test('_getDiff handles null diff responses', function(done) {
+        stub('gr-rest-api-interface', {
+          getDiff: function() { return Promise.resolve(null); },
+        });
+        element.changeNum = 123;
+        element.patchRange = {basePatchNum: 1, patchNum: 2};
+        element.path = 'file.txt';
+        element._getDiff().then(done);
+      });
     });
 
     suite('logged in', function() {
-
       setup(function() {
         stub('gr-rest-api-interface', {
           getLoggedIn: function() { return Promise.resolve(true); },
+          getPreferences: function() {
+            return Promise.resolve({time_format: 'HHMM_12'});
+          },
         });
         element = fixture('basic');
       });
@@ -467,6 +478,23 @@
           assert.equal(drafts.length, 1);
           assert.equal(drafts[0].id, id);
         });
+
+        test('_handleShowDiff reloads when expanded is made true',
+            function(done) {
+          element.expanded = false;
+          element.changeNum = element._comments.meta.changeNum;
+          element.patchRange = element._comments.meta.patchRange;
+          element.path = element._comments.meta.path;
+
+          var stub = sinon.stub(element, 'reload', function() {
+            assert.isTrue(stub.called);
+            stub.restore();
+            done();
+          });
+          var spy = sinon.spy(element, '_handleShowDiff');
+          element.set('expanded', true);
+          assert.isTrue(spy.called);
+        });
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
index c496703..54bccb3 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -13,7 +13,7 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-
+<link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
 <dom-module id="gr-patch-range-select">
@@ -28,12 +28,11 @@
     </style>
     Patch set:
     <span class="patchRange">
-      <select id="leftPatchSelect" on-change="_handlePatchChange">
-        <option value="PARENT"
-            selected$="[[_computeLeftSelected('PARENT', patchRange)]]">Base</option>
+      <select id="leftPatchSelect" bind-value="{{_leftSelected}}"
+          on-change="_handlePatchChange" is="gr-select">
+        <option value="PARENT">Base</option>
         <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
           <option value$="[[patchNum]]"
-              selected$="[[_computeLeftSelected(patchNum, patchRange)]]"
               disabled$="[[_computeLeftDisabled(patchNum, patchRange)]]">[[patchNum]]</option>
         </template>
       </select>
@@ -46,10 +45,10 @@
     </span>
     &rarr;
     <span class="patchRange">
-      <select id="rightPatchSelect" on-change="_handlePatchChange">
+      <select id="rightPatchSelect" bind-value="{{_rightSelected}}"
+          on-change="_handlePatchChange" is="gr-select">
         <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
           <option value$="[[patchNum]]"
-              selected$="[[_computeRightSelected(patchNum, patchRange)]]"
               disabled$="[[_computeRightDisabled(patchNum, patchRange)]]">[[patchNum]]</option>
         </template>
       </select>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 24d36c4..350429f 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -21,26 +21,29 @@
       availablePatches: Array,
       changeNum: String,
       filesWeblinks: Object,
-      patchRange: Object,
       path: String,
+      patchRange: {
+        type: Object,
+        observer: '_updateSelected'
+      },
+      _rightSelected: String,
+      _leftSelected: String,
+    },
+
+    _updateSelected: function() {
+      this._rightSelected = this.patchRange.patchNum;
+      this._leftSelected = this.patchRange.basePatchNum;
     },
 
     _handlePatchChange: function(e) {
-      var leftPatch = this.$.leftPatchSelect.value;
-      var rightPatch = this.$.rightPatchSelect.value;
+      var leftPatch = this._leftSelected;
+      var rightPatch = this._rightSelected;
       var rangeStr = rightPatch;
       if (leftPatch != 'PARENT') {
         rangeStr = leftPatch + '..' + rangeStr;
       }
       page.show('/c/' + this.changeNum + '/' + rangeStr + '/' + this.path);
-    },
-
-    _computeLeftSelected: function(patchNum, patchRange) {
-      return patchNum == patchRange.basePatchNum;
-    },
-
-    _computeRightSelected: function(patchNum, patchRange) {
-      return patchNum == patchRange.patchNum;
+      e.target.blur();
     },
 
     _computeLeftDisabled: function(patchNum, patchRange) {
@@ -51,5 +54,18 @@
       if (patchRange.basePatchNum == 'PARENT') { return false; }
       return parseInt(patchNum, 10) <= parseInt(patchRange.basePatchNum, 10);
     },
+
+    // On page load, the dom-if for options getting added occurs after
+    // the value was set in the select. This ensures that after they
+    // are loaded, the correct value will get selected.  I attempted to
+    // debounce these, but because they are detecting two different
+    // events, sometimes the timing was off and one ended up missing.
+    _synchronizeSelectionRight: function() {
+      this.$.rightPatchSelect.value = this._rightSelected;
+    },
+
+    _synchronizeSelectionLeft: function() {
+      this.$.leftPatchSelect.value = this._leftSelected;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index c7e1196..68eeaa9 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -63,9 +63,14 @@
       var showStub = sinon.stub(page, 'show');
       var leftSelectEl = element.$.leftPatchSelect;
       var rightSelectEl = element.$.rightPatchSelect;
+      var blurSpy = sinon.spy(leftSelectEl, 'blur');
       element.changeNum = '42';
       element.path = 'path/to/file.txt';
       element.availablePatches = ['1', '2', '3'];
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '3',
+      };
       flushAsynchronousOperations();
 
       var numEvents = 0;
@@ -77,6 +82,7 @@
               'Should navigate to /c/42/3/path/to/file.txt');
           leftSelectEl.value = '1';
           element.fire('change', {}, {node: leftSelectEl});
+          assert(blurSpy.called, 'Dropdown should be blurred after selection');
         } else if (numEvents == 2) {
           assert(showStub.lastCall.calledWithExactly(
               '/c/42/1..3/path/to/file.txt'),
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index 68b7528..eae77ef 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -173,9 +173,6 @@
         line.beforeNumber = 36;
         el.setAttribute('data-side', 'right');
 
-        var expectedStart = 6;
-        var expectedLength = line.text.length - expectedStart;
-
         element.annotate(el, line);
 
         assert.isFalse(annotateElementStub.called);
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
index 9a8ea37..98f6b01 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
@@ -21,26 +21,25 @@
   <template>
     <style>
       :host {
-        --gr-arrow-size: .6em;
+        --gr-arrow-size: .65em;
 
-        background-color: #fff;
-        border: 1px solid #000;
-        border-radius: .5em;
+        background-color: rgba(22, 22, 22, .9);
+        border-radius: 3px;
+        color: #fff;
         cursor: pointer;
-        padding: .3em;
+        font-family: var(--font-family);
+        padding: .5em .75em;
         position: absolute;
         white-space: nowrap;
       }
       .arrow {
-        background: #fff;
-        border: var(--gr-arrow-size) solid #000;
-        border-width: 0 1px 1px 0;
-        height: var(--gr-arrow-size);
-        left: calc(50% - 1em);
-        margin-top: .05em;
+        border: var(--gr-arrow-size) solid transparent;
+        border-top: var(--gr-arrow-size) solid rgba(22, 22, 22, 0.9);
+        height: 0;
+        left: calc(50% - var(--gr-arrow-size));
+        margin-top: .5em;
         position: absolute;
-        transform: rotate(45deg);
-        width: var(--gr-arrow-size);
+        width: 0;
       }
     </style>
     Press <strong>C</strong> to comment.
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index d565a12..568b5e0 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -48,7 +48,7 @@
     ],
 
     listeners: {
-      'tap': '_handleTap',
+      'mousedown': '_handleMouseDown', // See https://crbug.com/gerrit/4767
     },
 
     placeAbove: function(el) {
@@ -74,15 +74,22 @@
       return rect;
     },
 
+    _checkForModifiers: function(e) {
+      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || false;
+    },
+
     _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (e.keyCode === 67) { // 'c'
+        if (this._checkForModifiers(e)) { return; }
         e.preventDefault();
         this._fireCreateComment();
       }
     },
 
-    _handleTap: function() {
+    _handleMouseDown: function(e) {
+      e.preventDefault();
+      e.stopPropagation();
       this._fireCreateComment();
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index adc8532..c12966d 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -117,5 +117,13 @@
         document.createRange.restore();
       });
     });
+
+    test('_checkForModifiers', function() {
+      assert.isTrue(element._checkForModifiers({altKey: true}));
+      assert.isTrue(element._checkForModifiers({ctrlKey: true}));
+      assert.isTrue(element._checkForModifiers({metaKey: true}));
+      assert.isTrue(element._checkForModifiers({shiftKey: true}));
+      assert.isFalse(element._checkForModifiers({}));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
index c5c9377..9c5d6bf 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
@@ -14,7 +14,12 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-syntax-lib-loader/gr-syntax-lib-loader.html">
+
 <dom-module id="gr-syntax-layer">
+  <template>
+    <gr-syntax-lib-loader id="libLoader"></gr-syntax-lib-loader>
+  </template>
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff-highlight/gr-annotation.js"></script>
   <script src="gr-syntax-layer.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index 478bcc8..1103c03 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -47,7 +47,6 @@
     'text/x-yaml': 'yaml',
   };
   var ASYNC_DELAY = 10;
-  var HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
 
   var CLASS_WHITELIST = {
     'gr-diff gr-syntax gr-syntax-literal': true,
@@ -106,6 +105,7 @@
         value: function() { return []; },
       },
       _processHandle: Number,
+      _hljs: Object,
     },
 
     addListener: function(fn) {
@@ -254,13 +254,14 @@
         var nodeLength = GrAnnotation.getLength(node);
         // Note: HLJS may emit a span with class undefined when it thinks there
         // may be a syntax error.
-        if (node.tagName === 'SPAN' && node.className !== 'undefined' &&
-            CLASS_WHITELIST.hasOwnProperty(node.className)) {
-          result.push({
-            start: offset,
-            length: nodeLength,
-            className: node.className,
-          });
+        if (node.tagName === 'SPAN' && node.className !== 'undefined') {
+          if (CLASS_WHITELIST.hasOwnProperty(node.className)) {
+            result.push({
+              start: offset,
+              length: nodeLength,
+              className: node.className,
+            });
+          }
           if (node.children.length) {
             result = result.concat(this._rangesFromElement(node, offset));
           }
@@ -276,9 +277,8 @@
      * @param {!Object} state The processing state for the layer.
      */
     _processNextLine: function(state) {
-      var baseLine = undefined;
-      var revisionLine = undefined;
-      var hljs = this._getHighlightLib();
+      var baseLine;
+      var revisionLine;
 
       var section = this.diff.content[state.sectionIndex];
       if (section.ab) {
@@ -301,15 +301,15 @@
       var result;
 
       if (this._baseLanguage && baseLine !== undefined) {
-        result = hljs.highlight(this._baseLanguage, baseLine, true,
+        result = this._hljs.highlight(this._baseLanguage, baseLine, true,
             state.baseContext);
         this.push('_baseRanges', this._rangesFromString(result.value));
         state.baseContext = result.top;
       }
 
       if (this._revisionLanguage && revisionLine !== undefined) {
-        result = hljs.highlight(this._revisionLanguage, revisionLine, true,
-            state.revisionContext);
+        result = this._hljs.highlight(this._revisionLanguage, revisionLine,
+            true, state.revisionContext);
         this.push('_revisionRanges', this._rangesFromString(result.value));
         state.revisionContext = result.top;
       }
@@ -358,45 +358,10 @@
       });
     },
 
-    _getHighlightLib: function() {
-      return window.hljs;
-    },
-
-    _isHighlightLibLoaded: function() {
-      return !!this._getHighlightLib();
-    },
-
-    _configureHighlightLib: function() {
-      this._getHighlightLib().configure(
-          {classPrefix: 'gr-diff gr-syntax gr-syntax-'});
-    },
-
-    _getLibRoot: function() {
-      if (this._cachedLibRoot) { return this._cachedLibRoot; }
-
-      return this._cachedLibRoot = document.head
-          .querySelector('link[rel=import][href$="gr-app.html"]')
-          .href
-          .match(/(.+\/)elements\/gr-app\.html/)[1];
-    },
-    _cachedLibRoot: null,
-
-    /**
-     * Load and configure the HighlightJS library. If the library is already
-     * loaded, then do nothing and resolve.
-     * @return {Promise}
-     */
     _loadHLJS: function() {
-      if (this._isHighlightLibLoaded()) { return Promise.resolve(); }
-      return new Promise(function(resolve) {
-        var script = document.createElement('script');
-        script.src = this._getLibRoot() + HLJS_PATH;
-        script.onload = function() {
-          this._configureHighlightLib();
-          resolve();
-        }.bind(this);
-        Polymer.dom(this.root).appendChild(script);
+      return this.$.libLoader.get().then(function(hljs) {
+        this._hljs = hljs;
       }.bind(this));
-    }
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
index 5106671..aa37f1a 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
@@ -182,8 +182,8 @@
 
       var mockHLJS = getMockHLJS();
       var highlightSpy = sinon.spy(mockHLJS, 'highlight');
-      sandbox.stub(element, '_getHighlightLib',
-          function() { return mockHLJS; });
+      sandbox.stub(element.$.libLoader, 'get',
+          function() { return Promise.resolve(mockHLJS); });
       var processNextSpy = sandbox.spy(element, '_processNextLine');
       var processPromise = element.process();
 
@@ -370,6 +370,15 @@
       assert.equal(result[1].className, className);
     });
 
+    test('_rangesFromString whitelist allows recursion', function() {
+      var str = [
+          '<span class="non-whtelisted-class">',
+            '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
+          '</span>'].join('');
+      var result = element._rangesFromString(str);
+      assert.notEqual(result.length, 0);
+    });
+
     test('_isSectionDone', function() {
       var state = {sectionIndex: 0, lineIndex: 0};
       assert.isFalse(element._isSectionDone(state));
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.html b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.html
new file mode 100644
index 0000000..fedd22a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.html
@@ -0,0 +1,20 @@
+<!--
+Copyright (C) 2016 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.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-syntax-lib-loader">
+  <script src="gr-syntax-lib-loader.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
new file mode 100644
index 0000000..520f24d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
@@ -0,0 +1,93 @@
+// Copyright (C) 2016 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.
+(function() {
+  'use strict';
+
+  var HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
+  var LIB_ROOT_PATTERN = /(.+\/)elements\/gr-app\.html/;
+
+  Polymer({
+    is: 'gr-syntax-lib-loader',
+
+    properties: {
+      _state: {
+        type: Object,
+
+        // NOTE: intended singleton.
+        value: {
+          loaded: false,
+          loading: false,
+          callbacks: [],
+        },
+      }
+    },
+
+    get: function() {
+      return new Promise(function(resolve) {
+        // If the lib is totally loaded, resolve immediately.
+        if (this._state.loaded) {
+          resolve(this._getHighlightLib());
+          return;
+        }
+
+        // If the library is not currently being loaded, then start loading it.
+        if (!this._state.loading) {
+          this._state.loading = true;
+          this._loadHLJS().then(this._onLibLoaded.bind(this));
+        }
+
+        this._state.callbacks.push(resolve);
+      }.bind(this));
+    },
+
+    _onLibLoaded: function() {
+      var lib = this._getHighlightLib();
+      this._state.loaded = true;
+      this._state.loading = false;
+      this._state.callbacks.forEach(function(cb) { cb(lib); });
+      this._state.callbacks = [];
+    },
+
+    _getHighlightLib: function() {
+      return window.hljs;
+    },
+
+    _configureHighlightLib: function() {
+      this._getHighlightLib().configure(
+          {classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+    },
+
+    _getLibRoot: function() {
+      if (this._cachedLibRoot) { return this._cachedLibRoot; }
+
+      return this._cachedLibRoot = document.head
+          .querySelector('link[rel=import][href$="gr-app.html"]')
+          .href
+          .match(LIB_ROOT_PATTERN)[1];
+    },
+    _cachedLibRoot: null,
+
+    _loadHLJS: function() {
+      return new Promise(function(resolve) {
+        var script = document.createElement('script');
+        script.src = this._getLibRoot() + HLJS_PATH;
+        script.onload = function() {
+          this._configureHighlightLib();
+          resolve();
+        }.bind(this);
+        Polymer.dom(document.head).appendChild(script);
+      }.bind(this));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
new file mode 100644
index 0000000..13bea04
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
@@ -0,0 +1,95 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-syntax-lib-loader</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-syntax-lib-loader.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-syntax-lib-loader></gr-syntax-lib-loader>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-syntax-lib-loader tests', function() {
+    var element;
+    var resolveLoad;
+    var loadStub;
+
+    setup(function() {
+      element = fixture('basic');
+
+      loadStub = sinon.stub(element, '_loadHLJS', function() {
+        return new Promise(function(resolve) {
+          resolveLoad = resolve;
+        });
+      });
+
+      // Assert preconditions:
+      assert.isFalse(element._state.loaded);
+      assert.isFalse(element._state.loading);
+    });
+
+    teardown(function() {
+      if (window.hljs) {
+        delete window.hljs;
+      }
+      loadStub.restore();
+
+      // Because the element state is a singleton, clean it up.
+      element._state.loading = false;
+      element._state.loaded = false;
+      element._state.callbacks = [];
+    });
+
+    test('only load once', function(done) {
+      var firstCallHandler = sinon.stub();
+      element.get().then(firstCallHandler);
+
+      // It should now be in the loading state.
+      assert.isTrue(loadStub.called);
+      assert.isTrue(element._state.loading);
+      assert.isFalse(element._state.loaded);
+      assert.isFalse(firstCallHandler.called);
+
+      var secondCallHandler = sinon.stub();
+      element.get().then(secondCallHandler);
+
+      // No change in state.
+      assert.isTrue(element._state.loading);
+      assert.isFalse(element._state.loaded);
+      assert.isFalse(firstCallHandler.called);
+      assert.isFalse(secondCallHandler.called);
+
+      // Now load the library.
+      resolveLoad();
+      flush(function() {
+        // The state should be loaded and both handlers called.
+        assert.isFalse(element._state.loading);
+        assert.isTrue(element._state.loaded);
+        assert.isTrue(firstCallHandler.called);
+        assert.isTrue(secondCallHandler.called);
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index c20795b..a403e6c 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -22,11 +22,13 @@
 <link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
 <link rel="import" href="./core/gr-main-header/gr-main-header.html">
 <link rel="import" href="./core/gr-router/gr-router.html">
+<link rel="import" href="./core/gr-reporting/gr-reporting.html">
 
 <link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
 <link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
 <link rel="import" href="./change/gr-change-view/gr-change-view.html">
 <link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
+<link rel="import" href="./settings/gr-registration-dialog/gr-registration-dialog.html">
 <link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
 
 <link rel="import" href="./shared/gr-overlay/gr-overlay.html">
@@ -85,7 +87,8 @@
         color: #b71c1c;
       }
     </style>
-    <gr-main-header search-query="{{params.query}}"></gr-main-header>
+    <gr-main-header id="mainHeader" search-query="{{params.query}}">
+    </gr-main-header>
     <main>
       <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
         <gr-change-list-view
@@ -103,7 +106,8 @@
         <gr-change-view
             params="[[params]]"
             server-config="[[_serverConfig]]"
-            view-state="{{_viewState.changeView}}"></gr-change-view>
+            view-state="{{_viewState.changeView}}"
+            back-page="[[_lastSearchPage]]"></gr-change-view>
       </template>
       <template is="dom-if" if="[[_showDiffView]]" restamp="true">
         <gr-diff-view
@@ -111,7 +115,9 @@
             change-view-state="{{_viewState.changeView}}"></gr-diff-view>
       </template>
       <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
-        <gr-settings-view></gr-settings-view>
+        <gr-settings-view
+            on-account-detail-update="_handleAccountDetailUpdate">
+        </gr-settings-view>
       </template>
       <div id="errorView" class="errorView" hidden>
         <div class="errorEmoji">[[_lastError.emoji]]</div>
@@ -125,17 +131,26 @@
       |
       <a class="feedback"
           href="https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue"
-          target="_blank">
-        Report PolyGerrit Bug
-      </a>
+          target="_blank">Report PolyGerrit Bug</a>
+      <template is="dom-if" if="[[_computeShowGwtUiLink(_serverConfig)]]">
+        |
+        <a id="gwtLink" href$="/?polygerrit=0#[[_path]]" rel="external">GWT UI</a>
+      </template>
     </footer>
     <gr-overlay id="keyboardShortcuts" with-backdrop>
       <gr-keyboard-shortcuts-dialog
           view="[[params.view]]"
           on-close="_handleKeyboardShortcutDialogClose"></gr-keyboard-shortcuts-dialog>
     </gr-overlay>
+    <gr-overlay id="registration" with-backdrop>
+      <gr-registration-dialog
+          on-account-detail-update="_handleAccountDetailUpdate"
+          on-close="_handleRegistrationDialogClose">
+      </gr-registration-dialog>
+    </gr-overlay>
     <gr-error-manager></gr-error-manager>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-reporting id="reporting"></gr-reporting>
   </template>
   <script src="gr-app.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 0833a72..b758ff0 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -43,11 +43,14 @@
       _showSettingsView: Boolean,
       _viewState: Object,
       _lastError: Object,
+      _lastSearchPage: String,
+      _path: String,
     },
 
     listeners: {
       'page-error': '_handlePageError',
       'title-change': '_handleTitleChange',
+      'location-change': '_handleLocationChange',
     },
 
     observers: [
@@ -72,6 +75,7 @@
     },
 
     ready: function() {
+      this.$.reporting.appStarted();
       this._viewState = {
         changeView: {
           changeNum: null,
@@ -104,13 +108,18 @@
       this.set('_showChangeView', view === 'gr-change-view');
       this.set('_showDiffView', view === 'gr-diff-view');
       this.set('_showSettingsView', view === 'gr-settings-view');
+      if (this.params.justRegistered) {
+        this.$.registration.open();
+      }
     },
 
     _loadPlugins: function(plugins) {
+      Gerrit._setPluginsCount(plugins.length);
       for (var i = 0; i < plugins.length; i++) {
         var scriptEl = document.createElement('script');
         scriptEl.defer = true;
         scriptEl.src = '/' + plugins[i];
+        scriptEl.onerror = Gerrit._pluginInstalled;
         document.body.appendChild(scriptEl);
       }
     },
@@ -126,6 +135,11 @@
       return !!(account && Object.keys(account).length > 0);
     },
 
+    _computeShowGwtUiLink: function(config) {
+      return config.gerrit.web_uis &&
+          config.gerrit.web_uis.indexOf('GWT') !== -1;
+    },
+
     _handlePageError: function(e) {
       [
         '_showChangeListView',
@@ -152,6 +166,26 @@
       }
     },
 
+    _handleLocationChange: function(e) {
+      var hash = e.detail.hash.substring(1);
+      var pathname = e.detail.pathname;
+      if (pathname.indexOf('/c/') === 0 && parseInt(hash, 10) > 0) {
+        pathname += '@' + hash;
+      }
+      this.set('_path', pathname);
+      this._handleSearchPageChange();
+    },
+
+    _handleSearchPageChange: function() {
+      if (!this.params) {
+        return;
+      }
+      var viewsToCheck = ['gr-change-list-view', 'gr-dashboard-view'];
+      if (viewsToCheck.indexOf(this.params.view) !== -1) {
+        this.set('_lastSearchPage', location.pathname);
+      }
+    },
+
     _handleTitleChange: function(e) {
       if (e.detail.title) {
         document.title = e.detail.title + ' · Gerrit Code Review';
@@ -161,7 +195,7 @@
     },
 
     _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       if (e.keyCode === 191 && e.shiftKey) {  // '/' or '?' with shift key.
         this.$.keyboardShortcuts.open();
@@ -171,5 +205,17 @@
     _handleKeyboardShortcutDialogClose: function() {
       this.$.keyboardShortcuts.close();
     },
+
+    _handleAccountDetailUpdate: function(e) {
+      this.$.mainHeader.reload();
+      if (this.params.view === 'gr-settings-view') {
+        this.$$('gr-settings-view').reloadAccountDetail();
+      }
+    },
+
+    _handleRegistrationDialogClose: function(e) {
+      this.params.justRegistered = false;
+      this.$.registration.close();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
new file mode 100644
index 0000000..d03ab79
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -0,0 +1,98 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-app</title>
+
+<script src="../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-app.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-app id="app"></gr-app>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-app tests', function() {
+    var sandbox;
+    var element;
+
+    setup(function(done) {
+      sandbox = sinon.sandbox.create();
+      stub('gr-reporting', {
+        appStarted: sandbox.stub(),
+      });
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve(null); },
+        getConfig: function() {
+          return Promise.resolve({
+            gerrit: {web_uis: ['GWT', 'POLYGERRIT']},
+            plugin: {js_resource_paths: []},
+          });
+        },
+        getVersion: function() { return Promise.resolve(42); },
+      });
+
+      element = fixture('basic');
+      flush(done);
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('reporting', function() {
+      assert.isTrue(element.$.reporting.appStarted.calledOnce);
+    });
+
+    test('location change updates gwt footer', function(done) {
+      element._path = '/test/path';
+      flush(function() {
+        var gwtLink = element.$$('#gwtLink');
+        assert.equal(gwtLink.href,
+            'http://' + location.host + '/?polygerrit=0#/test/path');
+        done();
+      });
+    });
+
+    test('_handleLocationChange handles hashes', function(done) {
+      var curLocation = {
+        pathname: '/c/1/1/testfile.txt',
+        hash: '#2',
+        host: location.host,
+      };
+      sandbox.stub(element, '_handleSearchPageChange');
+      element._handleLocationChange({detail: curLocation});
+
+      flush(function() {
+        var gwtLink = element.$$('#gwtLink');
+        assert.equal(gwtLink.href,
+            'http://' + location.host + '/?polygerrit=0#/c/1/1/testfile.txt@2');
+        done();
+      });
+    });
+
+    test('sets plugins count', function() {
+      sandbox.stub(Gerrit, '_setPluginsCount');
+      element._loadPlugins([]);
+      assert.isTrue(Gerrit._setPluginsCount.calledWithExactly(0));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index 3930a78..2704ce5 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -17,6 +17,12 @@
   Polymer({
     is: 'gr-account-info',
 
+    /**
+     * Fired when account details are changed.
+     *
+     * @event account-detail-update
+     */
+
     properties: {
       mutable: {
         type: Boolean,
@@ -72,6 +78,7 @@
       return this.$.restAPI.setAccountName(this._account.name).then(function() {
         this.hasUnsavedChanges = false;
         this._saving = false;
+        this.fire('account-detail-update');
       }.bind(this));
     },
 
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
new file mode 100644
index 0000000..ee358d5
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
@@ -0,0 +1,98 @@
+<!--
+Copyright (C) 2016 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-registration-dialog">
+  <template>
+    <style include="gr-settings-styles"></style>
+    <style>
+      :host {
+        display: block;
+      }
+      main {
+        max-width: 46em;
+      }
+      hr {
+        margin-top: 1em;
+        margin-bottom: 1em;
+      }
+      header {
+        border-bottom: 1px solid #ddd;
+        font-weight: bold;
+      }
+      header,
+      main,
+      footer {
+        padding: .5em .65em;
+      }
+      footer {
+        display: flex;
+        justify-content: space-between;
+      }
+    </style>
+    <main class="gr-settings-styles">
+      <header>Please confirm your contact information</header>
+      <main>
+        <p>
+          The following contact information was automatically obtained when you
+          signed in to the site. This information is used to display who you are
+          to others, and to send updates to code reviews you have either started
+          or subscribed to.
+        </p>
+        <hr>
+        <section>
+          <div class="title">Full Name</div>
+          <input
+              is="iron-input"
+              id="name"
+              bind-value="{{_account.name}}"
+              disabled="[[_saving]]"
+              on-keydown="_handleNameKeydown">
+        </section>
+        <section>
+          <div class="title">Preferred Email</div>
+          <select
+              is="gr-select"
+              id="email"
+              bind-value="{{_account.email}}"
+              disabled="[[_saving]]">
+            <option value="[[_account.email]]">[[_account.email]]</option>
+            <template is="dom-repeat" items="[[_account.secondary_emails]]">
+              <option value="[[item]]">[[item]]</option>
+            </template>
+          </select>
+        </section>
+      </main>
+      <footer>
+        <gr-button
+            id="saveButton"
+            primary
+            disabled="[[_saving]]"
+            on-tap="_handleSave">Save</gr-button>
+        <gr-button
+            id="closeButton"
+            disabled="[[_saving]]"
+            on-tap="_handleClose">Close</gr-button>
+      </footer>
+    </main>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-registration-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
new file mode 100644
index 0000000..9acdba9
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
@@ -0,0 +1,79 @@
+// Copyright (C) 2016 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-registration-dialog',
+
+    /**
+     * Fired when account details are changed.
+     *
+     * @event account-detail-update
+     */
+
+    /**
+     * Fired when the close button is pressed.
+     *
+     * @event close
+     */
+
+    properties: {
+      _account: Object,
+      _saving: Boolean,
+    },
+
+    hostAttributes: {
+      role: 'dialog',
+    },
+
+    attached: function() {
+      this.$.restAPI.getAccount().then(function(account) {
+        this._account = account;
+      }.bind(this));
+    },
+
+    _handleNameKeydown: function(e) {
+      if (e.keyCode === 13) { // Enter
+        e.stopPropagation();
+        this._save();
+      }
+    },
+
+    _save: function() {
+      this._saving = true;
+      var promises = [
+        this.$.restAPI.setAccountName(this.$.name.value),
+        this.$.restAPI.setPreferredAccountEmail(this.$.email.value),
+      ];
+      return Promise.all(promises).then(function() {
+        this._saving = false;
+        this.fire('account-detail-update');
+      }.bind(this));
+    },
+
+    _handleSave: function(e) {
+      e.preventDefault();
+      this._save().then(function() {
+        this.fire('close');
+      }.bind(this));
+    },
+
+    _handleClose: function(e) {
+      e.preventDefault();
+      this._saving = true; // disable buttons indefinitely
+      this.fire('close');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
new file mode 100644
index 0000000..33f6aed
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
@@ -0,0 +1,147 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-registration-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-registration-dialog.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-registration-dialog></gr-registration-dialog>
+  </template>
+</test-fixture>
+
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-registration-dialog tests', function() {
+    var element;
+    var account;
+    var _listeners;
+
+    setup(function(done) {
+      _listeners = {};
+
+      account = {
+        name: 'name',
+        email: 'email',
+        secondary_emails: [
+          'email2',
+          'email3',
+        ],
+      };
+
+      stub('gr-rest-api-interface', {
+        getAccount: function() {
+          // Once the account is resolved, we can let the test proceed.
+          flush(done);
+          return Promise.resolve(account);
+        },
+        setAccountName: function(name) {
+          account.name = name;
+          return Promise.resolve();
+        },
+        setPreferredAccountEmail: function(email) {
+          account.email = email;
+          return Promise.resolve();
+        },
+      });
+
+      element = fixture('basic');
+    });
+
+    teardown(function() {
+      for (var eventType in _listeners) {
+        if (_listeners.hasOwnProperty(eventType)) {
+          element.removeEventListener(eventType, _listeners[eventType]);
+        }
+      }
+    });
+
+    function listen(eventType) {
+      return new Promise(function(resolve) {
+        _listeners[eventType] = function() { resolve(); };
+        element.addEventListener(eventType, _listeners[eventType]);
+      });
+    }
+
+    function save(opt_action) {
+      var promise = listen('account-detail-update');
+      if (opt_action) {
+        opt_action();
+      } else {
+        MockInteractions.tap(element.$.saveButton);
+      }
+      return promise;
+    }
+
+    function close(opt_action) {
+      var promise = listen('close');
+      if (opt_action) {
+        opt_action();
+      } else {
+        MockInteractions.tap(element.$.closeButton);
+      }
+      return promise;
+    }
+
+    test('fires the close event on close', function(done) {
+      close().then(done);
+    });
+
+    test('fires the close event on save', function(done) {
+      close(function() {
+        MockInteractions.tap(element.$.saveButton);
+      }).then(done);
+    });
+
+    test('saves name and preferred email', function(done) {
+      flush(function() {
+        element.$.name.value = 'new name';
+        element.$.email.value = 'email3';
+
+        // Nothing should be committed yet.
+        assert.equal(account.name, 'name');
+        assert.equal(account.email, 'email');
+
+        // Save and verify new values are committed.
+        save().then(function() {
+          assert.equal(account.name, 'new name');
+          assert.equal(account.email, 'email3');
+        }).then(done);
+      });
+    });
+
+    test('pressing enter saves name', function(done) {
+      element.$.name.value = 'entered name';
+      save(function() {
+        MockInteractions.pressAndReleaseKeyOn(element.$.name, 13);  // 'enter'
+      }).then(function() {
+        assert.equal(account.name, 'entered name');
+      }).then(done);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
index 4f1cb87..bc9630e 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -165,10 +165,9 @@
               <select
                   is="gr-select"
                   bind-value="{{_localPrefs.email_strategy}}">
-                <option value="ENABLED">Enabled</option>
-                <option
-                    value="CC_ON_OWN_COMMENTS">CC Me On Comments I Write</option>
-                <option value="DISABLED">Disabled</option>
+                <option value="CC_ON_OWN_COMMENTS">Every Comment</option>
+                <option value="ENABLED">Only Comments Left By Others</option>
+                <option value="DISABLED">None</option>
               </select>
             </span>
           </section>
@@ -211,7 +210,17 @@
             </span>
           </section>
           <section>
-            <span class="title">Columns</span>
+            <span class="title">Fit to Screen</span>
+            <span class="value">
+              <input
+                  id="lineWrapping"
+                  type="checkbox"
+                  checked$="[[_diffPrefs.line_wrapping]]"
+                  on-change="_handleLineWrappingChanged">
+            </span>
+          </section>
+          <section id="columnsPref" hidden$="[[_diffPrefs.line_wrapping]]">
+            <span class="title">Diff Width</span>
             <span class="value">
               <input
                   is="iron-input"
@@ -232,6 +241,17 @@
                   bind-value="{{_diffPrefs.tab_size}}">
             </span>
           </section>
+          <section hidden$="[[!_diffPrefs.font_size]]">
+            <span class="title">Font Size</span>
+            <span class="value">
+              <input
+                  is="iron-input"
+                  type="number"
+                  prevent-invalid-input
+                  allowed-pattern="[0-9]"
+                  bind-value="{{_diffPrefs.font_size}}">
+            </span>
+          </section>
           <section>
             <span class="title">Show Tabs</span>
             <span class="value">
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 6c62408..566b623 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -134,6 +134,13 @@
       this.unlisten(window, 'scroll', '_handleBodyScroll');
     },
 
+    reloadAccountDetail: function() {
+      Promise.all([
+        this.$.accountInfo.loadData(),
+        this.$.emailEditor.loadData(),
+      ]);
+    },
+
     _handleBodyScroll: function(e) {
       if (this._headerHeight === undefined) {
         var top = this.$.settingsNav.offsetTop;
@@ -199,6 +206,10 @@
       }.bind(this));
     },
 
+    _handleLineWrappingChanged: function() {
+      this.set('_diffPrefs.line_wrapping', this.$.lineWrapping.checked);
+    },
+
     _handleShowTabsChanged: function() {
       this.set('_diffPrefs.show_tabs', this.$.showTabs.checked);
     },
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index 4e98b43..cb9d2eb 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -92,8 +92,10 @@
       diffPreferences = {
         context: 10,
         tab_size: 8,
+        font_size: 12,
         line_length: 100,
         cursor_blink_rate: 0,
+        line_wrapping: false,
         intraline_difference: true,
         show_line_endings: true,
         show_tabs: true,
@@ -188,12 +190,16 @@
       // Rendered with the expected preferences selected.
       assert.equal(valueOf('Context', 'diffPreferences')
           .firstElementChild.bindValue, diffPreferences.context);
-      assert.equal(valueOf('Columns', 'diffPreferences')
+      assert.equal(valueOf('Diff Width', 'diffPreferences')
           .firstElementChild.bindValue, diffPreferences.line_length);
       assert.equal(valueOf('Tab Width', 'diffPreferences')
           .firstElementChild.bindValue, diffPreferences.tab_size);
+      assert.equal(valueOf('Font Size', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.font_size);
       assert.equal(valueOf('Show Tabs', 'diffPreferences')
           .firstElementChild.checked, diffPreferences.show_tabs);
+      assert.equal(valueOf('Fit to Screen', 'diffPreferences')
+          .firstElementChild.checked, diffPreferences.line_wrapping);
 
       assert.isFalse(element._diffPrefsChanged);
 
@@ -218,6 +224,16 @@
       });
     });
 
+    test('columns input is hidden with fit to scsreen is selected', function() {
+      assert.isFalse(element.$.columnsPref.hidden);
+
+      MockInteractions.tap(element.$.lineWrapping);
+      assert.isTrue(element.$.columnsPref.hidden);
+
+      MockInteractions.tap(element.$.lineWrapping);
+      assert.isFalse(element.$.columnsPref.hidden);
+    });
+
     test('menu', function(done) {
       assert.isFalse(element._menuChanged);
       assert.isFalse(element._prefsChanged);
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
index 66576a3..09f7381 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
@@ -138,7 +138,7 @@
       assert.isFalse(element._canAddProject({id: 'project b'}, 'filter 1'));
       assert.isFalse(element._canAddProject({id: 'project b'}, 'filter 2'));
 
-      // Can add a projec that is in the list using a new filter.
+      // Can add a project that is in the list using a new filter.
       assert.isTrue(element._canAddProject({id: 'project b'}, 'filter 3'));
     });
 
@@ -181,10 +181,11 @@
 
     test('_handleRemoveProject', function() {
       assert.equal(element._projectsToRemove, 0);
-
       var button = element.$$('table tbody tr:nth-child(2) gr-button');
       MockInteractions.tap(button);
 
+      flushAsynchronousOperations();
+
       var rows = element.$$('table tbody').querySelectorAll('tr');
       assert.equal(rows.length, 3);
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
index 360c281..dc1de17 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
@@ -53,12 +53,17 @@
         padding: 0;
         text-decoration: none;
       }
+      .transparentBackground,
+      gr-button.transparentBackground {
+        background-color: transparent;
+      }
     </style>
-    <div class="container">
+    <div class$="container [[_getBackgroundClass(transparentBackground)]]">
       <gr-account-link account="[[account]]"></gr-account-link>
       <gr-button
           hidden$="[[!removable]]" hidden
-          class="remove" on-tap="_handleRemoveTap">×</gr-button>
+          class$="remove [[_getBackgroundClass(transparentBackground)]]"
+          on-tap="_handleRemoveTap">×</gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index 45bf8fe..e33e1fc 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -28,6 +28,10 @@
         type: Boolean,
         reflectToAttribute: true,
       },
+      transparentBackground: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     ready: function() {
@@ -36,6 +40,10 @@
       }.bind(this));
     },
 
+    _getBackgroundClass: function(transparent) {
+      return transparent ? 'transparentBackground' : '';
+    },
+
     _handleRemoveTap: function(e) {
       e.preventDefault();
       this.fire('remove', {account: this.account});
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
index f136907..9bbcea5 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
@@ -47,5 +47,6 @@
       </span>
     </span>
   </template>
+  <script src="../../../scripts/util.js"></script>
   <script src="gr-account-label.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index 98871cb..40b7cf1 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -30,8 +30,11 @@
     },
 
     _computeAccountTitle: function(account) {
-      if (!account || !account.name) { return; }
-      var result = util.escapeHTML(account.name);
+      if (!account || (!account.name && !account.email)) { return; }
+      var result = '';
+      if (account.name) {
+        result += util.escapeHTML(account.name);
+      }
       if (account.email) {
         result += ' <' + util.escapeHTML(account.email) + '>';
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index eacd710..f3d8861 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -35,6 +35,10 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
       element = fixture('basic');
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
index d3585ef..8d89692 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
@@ -36,7 +36,8 @@
     <span>
       <a href$="[[_computeOwnerLink(account)]]">
         <gr-account-label account="[[account]]"
-            avatar-image-size="[[avatarImageSize]]"></gr-account-label>
+            avatar-image-size="[[avatarImageSize]]"
+            show-email="[[_computeShowEmail(account)]]"></gr-account-label>
       </a>
     </span>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index 058b27d..0c2ad0b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -30,5 +30,9 @@
       var accountID = account.email || account._account_id;
       return '/q/owner:' + encodeURIComponent(accountID) + '+status:open';
     },
+
+    _computeShowEmail: function(account) {
+      return !!(account && !account.name);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
index 2b5b831..e111d9b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
@@ -20,7 +20,6 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-<script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="gr-account-link.html">
 
@@ -35,6 +34,9 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
       element = fixture('basic');
     });
 
@@ -48,6 +50,10 @@
 
       assert.equal(element._computeOwnerLink({_account_id: 42}),
           '/q/owner:42+status:open');
+
+      assert.equal(element._computeShowEmail({name: 'asd'}), false);
+
+      assert.equal(element._computeShowEmail({}), true);
     });
 
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index cda2492..864114f 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -55,11 +55,11 @@
         bind-value="{{text}}"
         placeholder="[[placeholder]]"
         on-keydown="_handleInputKeydown"
-        on-focus="_updateSuggestions"
+        on-focus="_onInputFocus"
         autocomplete="off" />
     <div
         id="suggestions"
-        hidden$="[[_computeSuggestionsHidden(_suggestions)]]">
+        hidden$="[[_computeSuggestionsHidden(_suggestions, _focused)]]">
       <ul>
         <template is="dom-repeat" items="[[_suggestions]]">
           <li
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 0fc6b07..1c9e010 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -14,6 +14,8 @@
 (function() {
   'use strict';
 
+  var TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
+
   Polymer({
     is: 'gr-autocomplete',
 
@@ -29,6 +31,13 @@
      * @event cancel
      */
 
+    /**
+     * Fired on keydown to allow for custom hooks into autocomplete textbox
+     * behavior.
+     *
+     * @event input-keydown
+     */
+
     properties: {
 
       /**
@@ -76,6 +85,15 @@
         value: false,
       },
 
+      /**
+       * When true, tab key autocompletes but does not fire the commit event.
+       * See Issue 4556.
+       */
+      tabCompleteWithoutCommit: {
+        type: Boolean,
+        value: false,
+      },
+
       value: Object,
 
       /**
@@ -99,14 +117,19 @@
         value: false,
       },
 
+      _focused: {
+        type: Boolean,
+        value: false,
+      },
+
     },
 
     attached: function() {
-      this.listen(document.body, 'click', '_handleBodyClick');
+      this.listen(document.body, 'tap', '_handleBodyTap');
     },
 
     detached: function() {
-      this.unlisten(document.body, 'click', '_handleBodyClick');
+      this.unlisten(document.body, 'tap', '_handleBodyTap');
     },
 
     get focusStart() {
@@ -131,6 +154,11 @@
       this._disableSuggestions = false;
     },
 
+    _onInputFocus: function() {
+      this._focused = true;
+      this._updateSuggestions();
+    },
+
     _updateSuggestions: function() {
       if (!this.text || this._disableSuggestions) { return; }
       if (this.text.length < this.threshold) {
@@ -153,8 +181,8 @@
       }.bind(this));
     },
 
-    _computeSuggestionsHidden: function(suggestions) {
-      return !suggestions.length;
+    _computeSuggestionsHidden: function(suggestions, focused) {
+      return !(suggestions.length && focused);
     },
 
     _computeClass: function(borderless) {
@@ -181,12 +209,17 @@
           this._cancel();
           break;
         case 9: // Tab
+          if (this._suggestions.length > 0) {
+            e.preventDefault();
+            this._commit(this.tabCompleteWithoutCommit);
+          }
+          break;
         case 13: // Enter
           e.preventDefault();
           this._commit();
-          this._suggestions = [];
           break;
       }
+      this.fire('input-keydown', {keyCode: e.keyCode, input: this.$.input});
     },
 
     _cancel: function() {
@@ -199,34 +232,45 @@
       var completed = suggestions[index].value;
       if (this.multi) {
         // Append the completed text to the end of the string.
-        var shortStr = this.text.substring(0, this.text.lastIndexOf(' ') + 1);
-        this.value = shortStr + completed;
+        // Allow spaces within quoted terms.
+        var tokens = this.text.match(TOKENIZE_REGEX);
+        tokens[tokens.length - 1] = completed;
+        this.value = tokens.join(' ');
       } else {
         this.value = completed;
       }
     },
 
-    _handleBodyClick: function(e) {
+    _handleBodyTap: function(e) {
       var eventPath = Polymer.dom(e).path;
       for (var i = 0; i < eventPath.length; i++) {
-        if (eventPath[i] == this) {
+        if (eventPath[i] === this) {
           return;
         }
       }
-      this._suggestions = [];
+      this._focused = false;
     },
 
     _handleSuggestionTap: function(e) {
+      e.stopPropagation();
       this.$.cursor.setCursor(e.target);
       this._commit();
+      this.focus();
     },
 
-    _commit: function() {
+    /**
+     * Commits the suggestion, optionally firing the commit event.
+     *
+     * @param {Boolean} silent Allows for silent committing of an autocomplete
+     *     suggestion in order to handle cases like tab-to-complete without
+     *     firing the commit event.
+     */
+    _commit: function(silent) {
       // Allow values that are not in suggestion list iff suggestions are empty.
       if (this._suggestions.length > 0) {
         this._updateValue(this._suggestions, this._index);
       } else {
-        this.value = this.text;
+        this.value = this.text || '';
       }
 
       var value = this.value;
@@ -242,7 +286,10 @@
         }
       }
 
-      this.fire('commit', {value: value});
+      this._suggestions = [];
+      if (!silent) {
+        this.fire('commit', {value: value});
+      }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index f8b16b7..03fa8e43 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -57,6 +57,7 @@
       element.text = 'blah';
 
       assert.isTrue(queryStub.called);
+      element._focused = true;
 
       promise.then(function() {
         assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
@@ -69,7 +70,6 @@
         }
 
         assert.notEqual(element.$.cursor.index, -1);
-
         done();
       });
     });
@@ -85,6 +85,7 @@
 
       assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
 
+      element._focused = true;
       element.text = 'blah';
 
       promise.then(function() {
@@ -94,7 +95,6 @@
         element.addEventListener('cancel', cancelHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 27); // Esc
-
         assert.isTrue(cancelHandler.called);
         assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
 
@@ -117,7 +117,7 @@
 
       assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
       assert.equal(element.$.cursor.index, -1);
-
+      element._focused = true;
       element.text = 'blah';
 
       promise.then(function() {
@@ -241,5 +241,69 @@
         done();
       });
     });
+
+    test('tab key completes only when suggestions exist', function() {
+      var commitStub = sinon.stub(element, '_commit');
+      element._suggestions = [];
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9); // tab
+      assert.isFalse(commitStub.called);
+      element._suggestions = ['tunnel snakes rule!'];
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9); // tab
+      assert.isTrue(commitStub.called);
+      commitStub.restore();
+    });
+
+    test('tabCompleteWithoutCommit flag functions', function() {
+      var commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+      element._suggestions = ['tunnel snakes rule!'];
+      element.tabCompleteWithoutCommit = true;
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9); // tab
+      assert.isFalse(commitHandler.called);
+      element.tabCompleteWithoutCommit = false;
+      element._suggestions = ['tunnel snakes rule!'];
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9); // tab
+      assert.isTrue(commitHandler.called);
+    });
+
+    test('_focused flag properly triggered', function(done) {
+      flush(function() {
+        assert.isFalse(element._focused);
+        var input = element.$$('input');
+        MockInteractions.focus(input);
+        assert.isTrue(element._focused);
+        done();
+      });
+    });
+
+    test('_focused flag shows/hides the suggestions', function() {
+      var suggestions = ['hello', 'its me'];
+      assert.isTrue(element._computeSuggestionsHidden(suggestions, false));
+      assert.isFalse(element._computeSuggestionsHidden(suggestions, true));
+    });
+
+    test('tap on suggestion commits and refocuses on input', function() {
+      var focusSpy = sinon.spy(element, 'focus');
+      var commitSpy = sinon.spy(element, '_commit');
+      element._focused = true;
+      element._suggestions = [{name: 'first suggestion'}];
+      assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+      MockInteractions.tap(element.$$('#suggestions li:first-child'));
+      flushAsynchronousOperations();
+      assert.isTrue(focusSpy.called);
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+      assert.isTrue(element._focused);
+      focusSpy.restore();
+      commitSpy.restore();
+    });
+
+    test('input-keydown event fired', function() {
+      var listener = sinon.spy();
+      element.addEventListener('input-keydown', listener);
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9); // tab
+      flushAsynchronousOperations();
+      assert.isTrue(listener.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
index ae514ba..5ab27af 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
@@ -34,6 +34,9 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
       element = fixture('basic');
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
index c815ffd..164bb2d 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -22,7 +22,7 @@
   <template strip-whitespace>
     <style>
       :host {
-        background-color: #fff;
+        background-color: #f5f5f5;
         border: 1px solid #d1d2d3;
         border-radius: 2px;
         box-sizing: border-box;
@@ -30,10 +30,10 @@
         cursor: pointer;
         display: inline-block;
         font-family: var(--font-family);
-        font-size: 13px;
+        font-size: 12px;
         font-weight: bold;
         outline-width: 0;
-        padding: .3em .65em;
+        padding: .4em .85em;
         position: relative;
         text-align: center;
         -moz-user-select: none;
@@ -44,10 +44,17 @@
       :host([hidden]) {
         display: none;
       }
+      :host([primary]),
+      :host([secondary]) {
+        color: #fff;
+      }
       :host([primary]) {
         background-color: #4d90fe;
         border-color: #3079ed;
-        color: #fff;
+      }
+      :host([secondary]) {
+        background-color: #d14836;
+        border-color: transparent;
       }
       :host([small]) {
         font-size: 12px;
@@ -74,32 +81,53 @@
       :host([loading][disabled]) {
         cursor: wait;
       }
-      :host(:focus),
-      :host(:hover) {
-        border-color: #666;
+      :host(:focus:not([primary]:not[secondary])),
+      :host(:hover:not([primary]:not[secondary])) {
+        background-color: #f8f8f8;
+        border-color: #aaa;
       }
       :host(:active) {
         border-color: #d1d2d3;
         color: #aaa;
       }
+      :host([primary]:focus),
+      :host([secondary]:focus),
+      :host([primary]:active),
+      :host([secondary]:active) {
+        color: #fff;
+      }
       :host([primary]:focus) {
-        border-color: #fff;
         box-shadow: 0 0 1px #00f;
       }
       :host([primary]:hover) {
+        background-color: #4d90fe;
         border-color: #00F;
       }
+      :host([primary]:active),
+      :host([secondary]:active) {
+        box-shadow: none;
+      }
       :host([primary]:active) {
         border-color: #0c2188;
-        box-shadow: none;
-        color: #fff;
       }
-      :host([primary][loading]),
-      :host([primary][disabled]) {
+      :host([secondary]:focus) {
+        box-shadow: 0 0 1px #f00;
+      }
+      :host([secondary]:hover) {
+        background-color: #c53727;
+        border: 1px solid #b0281a;
+      }
+      :host([secondary]:active) {
+        border-color: #941c0c;
+      }
+      :host([primary][loading]) {
         background-color: #7caeff;
         border-color: transparent;
         color: #fff;
       }
+      :host([primary][disabled]) {
+        background-color: #888;
+      }
     </style>
     <content></content>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
index d8fc1df..54fddf5 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
@@ -22,11 +22,23 @@
     <style>
       :host {
         display: block;
+        max-height: 90vh;
+      }
+      .container {
+        display: flex;
+        flex-direction: column;
+        max-height: 90vh;
       }
       header {
         border-bottom: 1px solid #ddd;
+        flex-shrink: 0;
         font-weight: bold;
       }
+      main {
+        display: flex;
+        flex-shrink: 1;
+        width: 100%;
+      }
       header,
       main,
       footer {
@@ -34,15 +46,20 @@
       }
       footer {
         display: flex;
+        flex-shrink: 0;
         justify-content: space-between;
       }
     </style>
-    <header><content select=".header"></content></header>
-    <main><content select=".main"></content></main>
-    <footer>
-      <gr-button primary on-tap="_handleConfirmTap">[[confirmLabel]]</gr-button>
-      <gr-button on-tap="_handleCancelTap">Cancel</gr-button>
-    </footer>
+    <div class="container">
+      <header><content select=".header"></content></header>
+      <main><content select=".main"></content></main>
+      <footer>
+        <gr-button primary on-tap="_handleConfirmTap">
+          [[confirmLabel]]
+        </gr-button>
+        <gr-button on-tap="_handleCancelTap">Cancel</gr-button>
+      </footer>
+    </div>
   </template>
   <script src="gr-confirm-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index 0d3ea3d..7b3bc23 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -63,15 +63,6 @@
         type: String,
         value: ScrollBehavior.NEVER,
       },
-
-      /**
-       * When using the 'keep-visible' scroll behavior, set an offset to the top
-       * of the window for what is considered above the upper fold.
-       */
-      foldOffsetTop: {
-        type: Number,
-        value: 0,
-      },
     },
 
     detached: function() {
@@ -214,7 +205,7 @@
       }
 
       if (this.scroll === ScrollBehavior.KEEP_VISIBLE &&
-          top > window.pageYOffset + this.foldOffsetTop &&
+          top > window.pageYOffset &&
           top < window.pageYOffset + window.innerHeight) { return; }
 
       // Scroll the element to the middle of the window. Dividing by a third
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
index 5b12c8f..f6117e4 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -21,7 +21,7 @@
 
   var TimeFormats = {
     TIME_12: 'h:mm A', // 2:14 PM
-    TIME_24: 'H:mm', // 14:14
+    TIME_24: 'HH:mm', // 14:14
     MONTH_DAY: 'MMM DD', // Aug 29
     MONTH_DAY_YEAR: 'MMM DD, YYYY', // Aug 29, 1997
   };
@@ -111,7 +111,12 @@
       var date = moment(util.parseDate(dateStr));
       if (!date.isValid()) { return ''; }
       if (relative) {
-        return date.fromNow();
+        var dateFromNow = date.fromNow();
+        if (dateFromNow === 'a few seconds ago') {
+          return 'just now';
+        } else {
+          return dateFromNow;
+        }
       }
       var now = new Date();
       var format = TimeFormats.MONTH_DAY_YEAR;
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
index d1886e7..8d65bc3 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
@@ -98,13 +98,13 @@
       test('More than 24 hours but less than six months', function(done) {
         testDates('2015-07-29 20:34:14.985000000',
                   '2015-06-15 03:25:14.985000000',
-                  'Jun 15', 'Jun 15, 2015, 3:25', done);
+                  'Jun 15', 'Jun 15, 2015, 03:25', done);
       });
 
       test('More than six months', function(done) {
         testDates('2015-09-15 20:34:00.000000000',
                   '2015-01-15 03:25:00.000000000',
-                  'Jan 15, 2015', 'Jan 15, 2015, 3:25', done);
+                  'Jan 15, 2015', 'Jan 15, 2015, 03:25', done);
       });
     });
 
@@ -174,7 +174,7 @@
       });
 
       test('Default preferences are respected', function() {
-        assert.equal(element._timeFormat, 'H:mm');
+        assert.equal(element._timeFormat, 'HH:mm');
         assert.isFalse(element._relative);
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
index 76a9c77..4fec27f 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
@@ -18,14 +18,19 @@
 <dom-module id="gr-editable-label">
   <template>
     <style>
+      :host {
+        display: inline-block;
+      }
+      input,
+      label {
+        width: 100%;
+      }
       input {
         font: inherit;
-        max-width: 8em;
       }
       label {
         color: #777;
         display: inline-block;
-        max-width: 8em;
         overflow: hidden;
         text-overflow: ellipsis;
         white-space: nowrap;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
index f7c337b..72c7f6e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
@@ -33,6 +33,11 @@
     });
   };
 
+  GrChangeActionsInterface.prototype.setActionHidden = function(type, key,
+      hidden) {
+    return this._el.setActionHidden(type, key, hidden);
+  };
+
   GrChangeActionsInterface.prototype.add = function(type, label) {
     return this._el.addActionButton(type, label);
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index 4919a5a..93e676c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -119,5 +119,22 @@
         });
       });
     });
+
+    test('hide action buttons', function(done) {
+      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(function() {
+        var button = element.$$('[data-action-key="' + key + '"]');
+        assert.isOk(button);
+        assert.isFalse(button.hasAttribute('hidden'));
+        changeActions.setActionHidden(changeActions.ActionType.REVISION, key,
+            true);
+        flush(function() {
+          var button = element.$$('[data-action-key="' + key + '"]');
+          assert.isOk(button);
+          assert.isTrue(button.hasAttribute('hidden'));
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
index 2e5aa56..304982c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
@@ -42,6 +42,7 @@
 
     setup(function() {
       stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
         getAccount: function() { return Promise.resolve(null); },
       });
       element = fixture('basic');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index 1967b80..5c0535b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-js-api-interface">
@@ -23,4 +24,3 @@
   <script src="gr-js-api-interface.js"></script>
   <script src="gr-public-js-api.js"></script>
 </dom-module>
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
index 4dfcf48..bb407aa 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -21,6 +21,7 @@
     SUBMIT_CHANGE: 'submitchange',
     COMMENT: 'comment',
     REVERT: 'revert',
+    POST_REVERT: 'postrevert',
   };
 
   var Element = {
@@ -80,11 +81,11 @@
       this._eventCallbacks[eventName].push(callback);
     },
 
-    canSubmitChange: function() {
+    canSubmitChange: function(change, revision) {
       var submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
       var cancelSubmit = submitCallbacks.some(function(callback) {
         try {
-          return callback() === false;
+          return callback(change, revision) === false;
         } catch (err) {
           console.error(err);
         }
@@ -149,15 +150,29 @@
       });
     },
 
-    modifyRevertMsg: function(change, msg) {
+    modifyRevertMsg: function(change, revertMsg, origMsg) {
       this._getEventCallbacks(EventType.REVERT).forEach(function(callback) {
         try {
-          msg = callback(change, msg);
+          revertMsg = callback(change, revertMsg, origMsg);
         } catch (err) {
           console.error(err);
         }
       });
-      return msg;
+      return revertMsg;
+    },
+
+    getLabelValuesPostRevert: function(change) {
+      var labels = {};
+      this._getEventCallbacks(EventType.POST_REVERT).forEach(
+          function(callback) {
+            try {
+              labels = callback(change);
+            } catch (err) {
+              console.error(err);
+            }
+          }
+      );
+      return labels;
     },
 
     _getEventCallbacks: function(type) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index 46a555a..766da84 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -103,24 +103,42 @@
     });
 
     test('revert event', function(done) {
-      function appendToRevertMsg(c, msg) {
-        return msg + '\ninfo';
+      function appendToRevertMsg(c, revertMsg, originalMsg) {
+        return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
       }
       done();
 
-      assert.equal(element.modifyRevertMsg(null, 'test'), 'test');
+      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
       assert.equal(errorStub.callCount, 0);
 
       plugin.on(element.EventType.REVERT, throwErrFn);
       plugin.on(element.EventType.REVERT, appendToRevertMsg);
-      assert.equal(element.modifyRevertMsg(null, 'test'), 'test\ninfo');
+      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+                   'test\n> origTest\ninfo');
       assert.isTrue(errorStub.calledOnce);
 
       plugin.on(element.EventType.REVERT, appendToRevertMsg);
-      assert.equal(element.modifyRevertMsg(null, 'test'), 'test\ninfo\ninfo');
+      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+                   'test\n> origTest\ninfo\n> origTest\ninfo');
       assert.isTrue(errorStub.calledTwice);
     });
 
+    test('postrevert event', function(done) {
+      function getLabels(c) {
+        return {'Code-Review': 1};
+      }
+      done();
+
+      assert.deepEqual(element.getLabelValuesPostRevert(null), {});
+      assert.equal(errorStub.callCount, 0);
+
+      plugin.on(element.EventType.POST_REVERT, throwErrFn);
+      plugin.on(element.EventType.POST_REVERT, getLabels);
+      assert.deepEqual(element.getLabelValuesPostRevert(null),
+                       {'Code-Review': 1});
+      assert.isTrue(errorStub.calledOnce);
+    });
+
     test('labelchange event', function(done) {
       var testChange = {_number: 42};
       plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
@@ -156,5 +174,49 @@
       });
     });
 
+    test('_setPluginsCount', function(done) {
+      stub('gr-reporting', {
+        pluginsLoaded: function() {
+          assert.equal(Gerrit._pluginsPending, 0);
+          done();
+        }
+      });
+      Gerrit._setPluginsCount(0);
+    });
+
+    test('_arePluginsLoaded', function() {
+      assert.isFalse(Gerrit._arePluginsLoaded());
+      Gerrit._setPluginsCount(1);
+      assert.isFalse(Gerrit._arePluginsLoaded());
+      Gerrit._setPluginsCount(0);
+      assert.isTrue(Gerrit._arePluginsLoaded());
+    });
+
+    test('_pluginInstalled', function(done) {
+      stub('gr-reporting', {
+        pluginsLoaded: function() {
+          done();
+        }
+      });
+      Gerrit._setPluginsCount(2);
+      Gerrit._pluginInstalled();
+      assert.equal(Gerrit._pluginsPending, 1);
+      Gerrit._pluginInstalled();
+    });
+
+    test('install calls _pluginInstalled', function() {
+      var stub = sinon.stub(Gerrit, '_pluginInstalled');
+      Gerrit.install(function(p) { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      assert.isTrue(stub.calledOnce);
+      stub.restore();
+    });
+
+    test('install calls _pluginInstalled on error', function() {
+      var stub = sinon.stub(Gerrit, '_pluginInstalled');
+      Gerrit.install(function() {}, '0.0pre-alpha');
+      assert.isTrue(stub.calledOnce);
+      stub.restore();
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 21d76f1..ad8c135 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -64,6 +64,9 @@
 
   var Gerrit = window.Gerrit || {};
 
+  // Number of plugins to initialize, -1 means 'not yet known'.
+  Gerrit._pluginsPending = -1;
+
   Gerrit.getPluginName = function() {
     console.warn('Gerrit.getPluginName is not supported in PolyGerrit.',
         'Please use self.getPluginName() instead.');
@@ -85,12 +88,14 @@
     if (opt_version && opt_version !== API_VERSION) {
       console.warn('Only version ' + API_VERSION +
           ' is supported in PolyGerrit. ' + opt_version + ' was given.');
+      Gerrit._pluginInstalled();
       return;
     }
 
     // TODO(andybons): Polyfill currentScript for IE10/11 (edge supports it).
     var src = opt_src || (document.currentScript && document.currentScript.src);
     callback(new Plugin(src));
+    Gerrit._pluginInstalled();
   };
 
   Gerrit.getLoggedIn = function() {
@@ -101,5 +106,20 @@
     // NOOP since PolyGerrit doesn’t support GWT plugins.
   };
 
+  Gerrit._setPluginsCount = function(count) {
+    Gerrit._pluginsPending = count;
+    if (Gerrit._arePluginsLoaded()) {
+      document.createElement('gr-reporting').pluginsLoaded();
+    }
+  };
+
+  Gerrit._pluginInstalled = function() {
+    Gerrit._setPluginsCount(Gerrit._pluginsPending - 1);
+  };
+
+  Gerrit._arePluginsLoaded = function() {
+    return Gerrit._pluginsPending === 0;
+  };
+
   window.Gerrit = Gerrit;
 })(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
index cb852fd..a3eccb8 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
@@ -50,27 +50,19 @@
     _contentOrConfigChanged: function(content, config) {
       var output = Polymer.dom(this.$.output);
       output.textContent = '';
-      var parser = new GrLinkTextParser(config, function(text, href, html) {
+      var parser = new GrLinkTextParser(
+          config, function(text, href, fragment) {
         if (href) {
           var a = document.createElement('a');
           a.href = href;
           a.textContent = text;
           a.target = '_blank';
           output.appendChild(a);
-        } else if (html) {
-          var fragment = document.createDocumentFragment();
-          // Create temporary div to hold the nodes in.
-          var div = document.createElement('div');
-          div.innerHTML = html;
-          while (div.firstChild) {
-            fragment.appendChild(div.firstChild);
-          }
+        } else if (fragment) {
           output.appendChild(fragment);
-        } else {
-          output.appendChild(document.createTextNode(text));
         }
       });
       parser.parse(content);
-    }
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
index 5203520..f530331 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
@@ -123,6 +123,35 @@
       assert.equal(linkEl2.textContent, 'Issue 3450');
     });
 
+    test('Change-Id pattern parsed before bug pattern', function() {
+      // "Change-Id:" pattern.
+      var changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+      var prefix = 'Change-Id: ';
+
+      // "Issue/Bug" pattern.
+      var bug = 'Issue 3650';
+
+      var changeUrl = '/q/' + changeID;
+      var bugUrl = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+
+      element.content = prefix + changeID + bug;
+
+      var textNode = element.$.output.childNodes[0];
+      var changeLinkEl = element.$.output.childNodes[1];
+      var bugLinkEl = element.$.output.childNodes[2];
+
+      assert.equal(textNode.textContent, prefix);
+
+      assert.equal(changeLinkEl.target, '_blank');
+      assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
+      assert.equal(changeLinkEl.textContent, changeID);
+
+      assert.equal(bugLinkEl.target, '_blank');
+      assert.equal(bugLinkEl.href, bugUrl);
+      assert.equal(bugLinkEl.textContent, 'Issue 3650');
+    });
+
+
     test('html field in link config', function() {
       element.content = 'google:do a barrel roll';
       var linkEl = element.$.output.childNodes[0];
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
index b4b1678..5e1ff62 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -27,13 +27,83 @@
   this.callback(text, href);
 };
 
-GrLinkTextParser.prototype.addHTML = function(html) {
-  this.callback(null, null, html);
+GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
+  this.sortArrayReverse(outputArray);
+  var fragment = document.createDocumentFragment();
+  var cursor = text.length;
+
+  // Start inserting linkified URLs from the end of the String. That way, the
+  // string positions of the items don't change as we iterate through.
+  outputArray.forEach(function(item) {
+    // Add any text between the current linkified item and the item added before
+    // if it exists.
+    if (item.position + item.length !== cursor) {
+      fragment.insertBefore(
+          document.createTextNode(
+              text.slice(item.position + item.length, cursor)),
+          fragment.firstChild);
+    }
+    fragment.insertBefore(item.html, fragment.firstChild);
+    cursor = item.position;
+  });
+
+  // Add the beginning portion at the end.
+  if (cursor !== 0) {
+    fragment.insertBefore(
+        document.createTextNode(text.slice(0, cursor)), fragment.firstChild);
+  }
+
+  this.callback(null, null, fragment);
+};
+
+GrLinkTextParser.prototype.sortArrayReverse = function(outputArray) {
+  outputArray.sort(function(a, b) {return b.position - a.position});
+};
+
+GrLinkTextParser.prototype.addItem =
+    function(text, href, html, position, length, outputArray) {
+  var htmlOutput = '';
+
+  if (href) {
+    var a = document.createElement('a');
+    a.href = href;
+    a.textContent = text;
+    a.target = '_blank';
+    htmlOutput = a;
+  } else if (html) {
+    var fragment = document.createDocumentFragment();
+    // Create temporary div to hold the nodes in.
+    var div = document.createElement('div');
+    div.innerHTML = html;
+    while (div.firstChild) {
+      fragment.appendChild(div.firstChild);
+    }
+    htmlOutput = fragment;
+  }
+
+  outputArray.push({
+    html: htmlOutput,
+    position: position,
+    length: length,
+  });
+};
+
+GrLinkTextParser.prototype.addLink =
+    function(text, href, position, length, outputArray) {
+  if (!text) {
+    return;
+  }
+  this.addItem(text, href, null, position, length, outputArray);
+};
+
+GrLinkTextParser.prototype.addHTML =
+    function(html, position, length, outputArray) {
+  this.addItem(null, null, html, position, length, outputArray);
 };
 
 GrLinkTextParser.prototype.parse = function(text) {
   linkify(text, {
-    callback: this.parseChunk.bind(this)
+    callback: this.parseChunk.bind(this),
   });
 };
 
@@ -46,6 +116,8 @@
 };
 
 GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
+  // The outputArray is used to store all of the matches found for all patterns.
+  var outputArray = [];
   for (var p in patterns) {
     if (patterns[p].enabled != null && patterns[p].enabled == false) {
       continue;
@@ -66,22 +138,29 @@
     var pattern = new RegExp(patterns[p].match, 'g');
 
     var match;
-    while ((match = pattern.exec(text)) != null) {
-      var before = text.substr(0, match.index);
-      this.addText(before);
-      text = text.substr(match.index + match[0].length);
+    var textToCheck = text;
+    var susbtrIndex = 0;
+
+    while ((match = pattern.exec(textToCheck)) != null) {
+      textToCheck = textToCheck.substr(match.index + match[0].length);
       var result = match[0].replace(pattern,
           patterns[p].html || patterns[p].link);
 
       if (patterns[p].html) {
-        this.addHTML(result);
+        this.addHTML(
+            result, susbtrIndex + match.index, match[0].length, outputArray);
       } else if (patterns[p].link) {
-        this.addText(match[0], result);
+        this.addLink(match[0], result,
+            susbtrIndex + match.index, match[0].length, outputArray);
       } else {
         throw Error('linkconfig entry ' + p +
             ' doesn’t contain a link or html attribute.');
       }
+
+      // Update the substring location so we know where we are in relation to
+      // the initial full text string.
+      susbtrIndex = susbtrIndex + match.index + match[0].length;
     }
   }
-  this.addText(text);
+  this.processLinks(text, outputArray);
 };
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
index da28e49..f4a389a 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -25,25 +25,24 @@
     ],
 
     detached: function() {
-      // For good measure.
-      Gerrit.KeyboardShortcutBehavior.enabled = true;
+      Gerrit.KeyboardShortcutBehavior.enable(this._id());
     },
 
     open: function() {
       return new Promise(function(resolve) {
-        Gerrit.KeyboardShortcutBehavior.enabled = false;
+        Gerrit.KeyboardShortcutBehavior.disable(this._id());
         Polymer.IronOverlayBehaviorImpl.open.apply(this, arguments);
         this._awaitOpen(resolve);
       }.bind(this));
     },
 
     close: function() {
-      Gerrit.KeyboardShortcutBehavior.enabled = true;
+      Gerrit.KeyboardShortcutBehavior.enable(this._id());
       Polymer.IronOverlayBehaviorImpl.close.apply(this, arguments);
     },
 
     cancel: function() {
-      Gerrit.KeyboardShortcutBehavior.enabled = true;
+      Gerrit.KeyboardShortcutBehavior.enable(this._id());
       Polymer.IronOverlayBehaviorImpl.cancel.apply(this, arguments);
     },
 
@@ -72,5 +71,9 @@
       }.bind(this);
       step.call(this);
     },
+
+    _id: function() {
+      return this.getAttribute('id') || 'global';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
index 4980cba..f34ffcf 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <script src="../../../bower_components/es6-promise/dist/es6-promise.min.js"></script>
 <script src="../../../bower_components/fetch/fetch.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 2f109c9..672afa8 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -62,11 +62,18 @@
     COMMIT_FOOTERS: 17,
 
     // Include push certificate information along with any patch sets.
-    PUSH_CERTIFICATES: 18
+    PUSH_CERTIFICATES: 18,
+
+    // Include change's reviewer updates.
+    REVIEWER_UPDATES: 19,
+
+    // Set the submittable boolean.
+    SUBMITTABLE: 20
   };
 
   Polymer({
     is: 'gr-rest-api-interface',
+    behaviors: [Gerrit.PathListBehavior],
 
     /**
      * Fired when an server error occurs.
@@ -94,7 +101,6 @@
     fetchJSON: function(url, opt_errFn, opt_cancelCondition, opt_params,
         opt_opts) {
       opt_opts = opt_opts || {};
-
       var fetchOptions = {
         credentials: 'same-origin',
         headers: opt_opts.headers,
@@ -185,9 +191,11 @@
           auto_hide_diff_table_header: true,
           context: 10,
           cursor_blink_rate: 0,
+          font_size: 12,
           ignore_whitespace: 'IGNORE_NONE',
           intraline_difference: true,
           line_length: 100,
+          line_wrapping: false,
           show_line_endings: true,
           show_tabs: true,
           show_whitespace_errors: true,
@@ -232,12 +240,39 @@
 
     setPreferredAccountEmail: function(email, opt_errFn, opt_ctx) {
       return this.send('PUT', '/accounts/self/emails/' +
-          encodeURIComponent(email) + '/preferred', null, opt_errFn, opt_ctx);
+          encodeURIComponent(email) + '/preferred', null,
+          opt_errFn, opt_ctx).then(function() {
+        // If result of getAccountEmails is in cache, update it in the cache
+        // so we don't have to invalidate it.
+        var cachedEmails = this._cache['/accounts/self/emails'];
+        if (cachedEmails) {
+          var emails = cachedEmails.map(function(entry) {
+            if (entry.email === email) {
+              return {email: email, preferred: true};
+            } else {
+              return {email: email};
+            }
+          });
+          this._cache['/accounts/self/emails'] = emails;
+        }
+      }.bind(this));
     },
 
     setAccountName: function(name, opt_errFn, opt_ctx) {
       return this.send('PUT', '/accounts/self/name', {name: name}, opt_errFn,
-          opt_ctx);
+          opt_ctx).then(function(response) {
+        // If result of getAccount is in cache, update it in the cache
+        // so we don't have to invalidate it.
+        var cachedAccount = this._cache['/accounts/self/detail'];
+        if (cachedAccount) {
+          return this.getResponseObject(response).then(function(newName) {
+            // Replace object in cache with new object to force UI updates.
+            // TODO(logan): Polyfill for Object.assign in IE
+            this._cache['/accounts/self/detail'] = Object.assign(
+                {}, cachedAccount, {name: newName});
+          }.bind(this));
+        }
+      }.bind(this));
     },
 
     getAccountGroups: function() {
@@ -312,6 +347,10 @@
           ListChangesOption.LABELS,
           ListChangesOption.DETAILED_ACCOUNTS
       );
+      // Issue 4524: respect legacy token with max sortkey.
+      if (opt_offset === 'n,z') {
+        opt_offset = 0;
+      }
       var params = {
         n: changesPerPage,
         O: options,
@@ -348,7 +387,8 @@
       var options = this._listChangesOptionsToHex(
           ListChangesOption.ALL_REVISIONS,
           ListChangesOption.CHANGE_ACTIONS,
-          ListChangesOption.DOWNLOAD_COMMANDS
+          ListChangesOption.DOWNLOAD_COMMANDS,
+          ListChangesOption.SUBMITTABLE
       );
       return this._getChangeDetail(changeNum, options, opt_errFn,
           opt_cancelCondition);
@@ -392,13 +432,12 @@
 
     getChangeFilePathsAsSpeciallySortedArray: function(changeNum, patchRange) {
       return this.getChangeFiles(changeNum, patchRange).then(function(files) {
-        return Object.keys(files).sort(this._specialFilePathCompare.bind(this));
+        return Object.keys(files).sort(this.specialFilePathCompare);
       }.bind(this));
     },
 
     _normalizeChangeFilesResponse: function(response) {
-      var paths = Object.keys(response).sort(
-          this._specialFilePathCompare.bind(this));
+      var paths = Object.keys(response).sort(this.specialFilePathCompare);
       var files = [];
       for (var i = 0; i < paths.length; i++) {
         var info = response[paths[i]];
@@ -410,42 +449,6 @@
       return files;
     },
 
-    _specialFilePathCompare: function(a, b) {
-      var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
-      // The commit message always goes first.
-      if (a === COMMIT_MESSAGE_PATH) {
-        return -1;
-      }
-      if (b === COMMIT_MESSAGE_PATH) {
-        return 1;
-      }
-
-      var aLastDotIndex = a.lastIndexOf('.');
-      var aExt = a.substr(aLastDotIndex + 1);
-      var aFile = a.substr(0, aLastDotIndex);
-
-      var bLastDotIndex = b.lastIndexOf('.');
-      var bExt = b.substr(bLastDotIndex + 1);
-      var bFile = a.substr(0, bLastDotIndex);
-
-      // Sort header files above others with the same base name.
-      var headerExts = ['h', 'hxx', 'hpp'];
-      if (aFile.length > 0 && aFile === bFile) {
-        if (headerExts.indexOf(aExt) !== -1 &&
-            headerExts.indexOf(bExt) !== -1) {
-          return a.localeCompare(b);
-        }
-        if (headerExts.indexOf(aExt) !== -1) {
-          return -1;
-        }
-        if (headerExts.indexOf(bExt) !== -1) {
-          return 1;
-        }
-      }
-
-      return a.localeCompare(b);
-    },
-
     getChangeRevisionActions: function(changeNum, patchNum) {
       return this.fetchJSON(
           this.getChangeActionURL(changeNum, patchNum, '/actions')).then(
@@ -467,8 +470,22 @@
       });
     },
 
-    getSuggestedProjects: function(inputVal, opt_errFn, opt_ctx) {
-      return this.fetchJSON('/projects/', opt_errFn, opt_ctx, {p: inputVal});
+    getSuggestedGroups: function(inputVal, opt_n, opt_errFn, opt_ctx) {
+      var params = {s: inputVal};
+      if (opt_n) { params.n = opt_n; }
+      return this.fetchJSON('/groups/', opt_errFn, opt_ctx, params);
+    },
+
+    getSuggestedProjects: function(inputVal, opt_n, opt_errFn, opt_ctx) {
+      var params = {p: inputVal};
+      if (opt_n) { params.n = opt_n; }
+      return this.fetchJSON('/projects/', opt_errFn, opt_ctx, params);
+    },
+
+    getSuggestedAccounts: function(inputVal, opt_n, opt_errFn, opt_ctx) {
+      var params = {q: inputVal, suggest: null};
+      if (opt_n) { params.n = opt_n; }
+      return this.fetchJSON('/accounts/', opt_errFn, opt_ctx, params);
     },
 
     addChangeReviewer: function(changeNum, reviewerID) {
@@ -531,7 +548,7 @@
       ].join(' ');
       var params = {
         O: options,
-        q: query
+        q: query,
       };
       return this.fetchJSON('/changes/', null, null, params);
     },
@@ -877,5 +894,10 @@
     deleteAccountSSHKey: function(id) {
       return this.send('DELETE', '/accounts/self/sshkeys/' + id);
     },
+
+    deleteVote: function(changeID, account, label) {
+      return this.send('DELETE', '/changes/' + changeID +
+          '/reviewers/' + account + '/votes/' + encodeURIComponent(label));
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 8dda2ce..392c320 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -215,28 +215,54 @@
     test('special file path sorting', function() {
       assert.deepEqual(
           ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
-              element._specialFilePathCompare),
+              element.specialFilePathCompare),
           ['/COMMIT_MSG', '.a', '.b', 'file']);
 
       assert.deepEqual(
           ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
-              element._specialFilePathCompare),
+              element.specialFilePathCompare),
           ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']);
 
       assert.deepEqual(
           ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
-              element._specialFilePathCompare),
+              element.specialFilePathCompare),
           ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']);
 
       assert.deepEqual(
           ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
-              element._specialFilePathCompare),
+              element.specialFilePathCompare),
           ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
 
       assert.deepEqual(
           ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
-              element._specialFilePathCompare),
+              element.specialFilePathCompare),
           ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
+
+      // Regression test for Issue 4448.
+      assert.deepEqual([
+          'minidump/minidump_memory_writer.cc',
+          'minidump/minidump_memory_writer.h',
+          'minidump/minidump_thread_writer.cc',
+          'minidump/minidump_thread_writer.h',
+          ]
+        .sort(element.specialFilePathCompare),
+          [
+            'minidump/minidump_memory_writer.h',
+            'minidump/minidump_memory_writer.cc',
+            'minidump/minidump_thread_writer.h',
+            'minidump/minidump_thread_writer.cc',
+          ]);
+
+      // Regression test for Issue 4545.
+      assert.deepEqual([
+          'task_test.go',
+          'task.go',
+          ]
+        .sort(element.specialFilePathCompare),
+          [
+            'task.go',
+            'task_test.go',
+          ]);
     });
 
     test('rebase always enabled', function(done) {
@@ -285,7 +311,7 @@
           text: function() { return Promise.resolve(')]}\'{}'); }
         },
       ];
-      var fetchStub = sandbox.stub(window, 'fetch', function(url) {
+      sandbox.stub(window, 'fetch', function(url) {
         if (url === '/accounts/self/detail') {
           return Promise.resolve(responses.shift());
         }
@@ -298,5 +324,11 @@
         });
       });
     });
+
+    test('legacy n,z key in change url is replaced', function() {
+      var stub = sandbox.stub(element, 'fetchJSON');
+      element.getChanges(1, null, 'n,z');
+      assert.equal(stub.args[0][3].S, 0);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
index 9e14f08..bef260e9 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -16,40 +16,33 @@
 
   Polymer({
     is: 'gr-select',
-
     extends: 'select',
-
     properties: {
       bindValue: {
         type: String,
         notify: true,
+        observer: '_updateValue',
       },
     },
 
-    observers: [
-      '_valueChanged(bindValue)',
-    ],
+    listeners: {
+      change: '_valueChanged',
+      'dom-change': '_updateValue',
+    },
 
-    attached: function() {
-      this.addEventListener('change', function() {
-        this.bindValue = this.value;
-      });
+    _updateValue: function() {
+      if (this.bindValue) {
+        this.value = this.bindValue;
+      }
+    },
+
+    _valueChanged: function() {
+      this.bindValue = this.value;
     },
 
     ready: function() {
       // If not set via the property, set bind-value to the element value.
       if (!this.bindValue) { this.bindValue = this.value; }
     },
-
-    _valueChanged: function(bindValue) {
-      var options = Polymer.dom(this.root).querySelectorAll('option');
-      for (var i = 0; i < options.length; i++) {
-        if (options[i].getAttribute('value') === bindValue + '') {
-          options[i].setAttribute('selected', true);
-          this.value = bindValue;
-          break;
-        }
-      }
-    },
   });
 })();
diff --git a/polygerrit-ui/app/index.html b/polygerrit-ui/app/index.html
index 0e00f77..a5cddad 100644
--- a/polygerrit-ui/app/index.html
+++ b/polygerrit-ui/app/index.html
@@ -23,6 +23,7 @@
 <link rel="stylesheet" href="/styles/fonts.css">
 <link rel="stylesheet" href="/styles/main.css">
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="preload" href="/elements/gr-app.js">
 <link rel="import" href="/elements/gr-app.html">
 
 <body unresolved>
diff --git a/polygerrit-ui/app/styles/app-theme.html b/polygerrit-ui/app/styles/app-theme.html
index ecf4ac6..faf45d8 100644
--- a/polygerrit-ui/app/styles/app-theme.html
+++ b/polygerrit-ui/app/styles/app-theme.html
@@ -20,7 +20,7 @@
   --selection-background-color: #ebf5fb;
   --default-text-color: #000;
   --view-background-color: #fff;
-  --default-horizontal-margin: 1.25rem;
+  --default-horizontal-margin: 1rem;
   --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
   --monospace-font-family: 'Source Code Pro', Menlo, 'Lucida Console', Monaco, monospace;
 
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index d3cb316..a25656f 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -22,18 +22,20 @@
 <script src="../bower_components/web-component-tester/browser.js"></script>
 <script>
   var testFiles = [];
-  var basePath = '../elements/';
+  var elementsPath = '../elements/';
+  var behaviorsPath = '../behaviors/';
 
+  // Elements tests.
   [
-    'change-list/gr-change-list-item/gr-change-list-item_test.html',
-    'change-list/gr-change-list/gr-change-list_test.html',
     'change/gr-account-entry/gr-account-entry_test.html',
     'change/gr-account-list/gr-account-list_test.html',
     'change/gr-change-actions/gr-change-actions_test.html',
     'change/gr-change-metadata/gr-change-metadata_test.html',
     'change/gr-change-view/gr-change-view_test.html',
     'change/gr-comment-list/gr-comment-list_test.html',
+    'change/gr-commit-info/gr-commit-info_test.html',
     'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
+    'change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html',
     'change/gr-download-dialog/gr-download-dialog_test.html',
     'change/gr-file-list/gr-file-list_test.html',
     'change/gr-message/gr-message_test.html',
@@ -41,38 +43,45 @@
     'change/gr-related-changes-list/gr-related-changes-list_test.html',
     'change/gr-reply-dialog/gr-reply-dialog_test.html',
     'change/gr-reviewer-list/gr-reviewer-list_test.html',
+    'change-list/gr-change-list/gr-change-list_test.html',
+    'change-list/gr-change-list-item/gr-change-list-item_test.html',
+    'change-list/gr-change-list-view/gr-change-list-view_test.html',
     'core/gr-account-dropdown/gr-account-dropdown_test.html',
     'core/gr-error-manager/gr-error-manager_test.html',
     'core/gr-main-header/gr-main-header_test.html',
+    'core/gr-reporting/gr-reporting_test.html',
     'core/gr-search-bar/gr-search-bar_test.html',
+    'diff/gr-diff/gr-diff-group_test.html',
+    'diff/gr-diff/gr-diff_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
-    'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
     'diff/gr-diff-comment/gr-diff-comment_test.html',
+    'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
-    'diff/gr-diff-highlight/gr-diff-highlight_test.html',
     'diff/gr-diff-highlight/gr-annotation_test.html',
+    'diff/gr-diff-highlight/gr-diff-highlight_test.html',
     'diff/gr-diff-preferences/gr-diff-preferences_test.html',
     'diff/gr-diff-processor/gr-diff-processor_test.html',
     'diff/gr-diff-selection/gr-diff-selection_test.html',
     'diff/gr-diff-view/gr-diff-view_test.html',
-    'diff/gr-diff/gr-diff-group_test.html',
-    'diff/gr-diff/gr-diff_test.html',
     'diff/gr-patch-range-select/gr-patch-range-select_test.html',
     'diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html',
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
     'diff/gr-syntax-layer/gr-syntax-layer_test.html',
+    'diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html',
+    'gr-app_test.html',
     'settings/gr-account-info/gr-account-info_test.html',
     'settings/gr-email-editor/gr-email-editor_test.html',
     'settings/gr-group-list/gr-group-list_test.html',
     'settings/gr-http-password/gr-http-password_test.html',
     'settings/gr-menu-editor/gr-menu-editor_test.html',
+    'settings/gr-registration-dialog/gr-registration-dialog_test.html',
     'settings/gr-settings-view/gr-settings-view_test.html',
     'settings/gr-ssh-editor/gr-ssh-editor_test.html',
     'settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html',
-    'shared/gr-autocomplete/gr-autocomplete_test.html',
     'shared/gr-account-label/gr-account-label_test.html',
     'shared/gr-account-link/gr-account-link_test.html',
     'shared/gr-alert/gr-alert_test.html',
+    'shared/gr-autocomplete/gr-autocomplete_test.html',
     'shared/gr-avatar/gr-avatar_test.html',
     'shared/gr-change-star/gr-change-star_test.html',
     'shared/gr-confirm-dialog/gr-confirm-dialog_test.html',
@@ -88,10 +97,18 @@
     'shared/gr-select/gr-select_test.html',
     'shared/gr-storage/gr-storage_test.html',
   ].forEach(function(file) {
-    file = basePath + file;
+    file = elementsPath + file;
     testFiles.push(file);
     testFiles.push(file + '?dom=shadow');
   });
 
+  // Behaviors tests.
+  [
+    'gr-path-list-behavior/gr-path-list-behavior_test.html',
+  ].forEach(function(file) {
+    file = behaviorsPath + file;
+    testFiles.push(file);
+  });
+
   WCT.loadSuites(testFiles);
 </script>
diff --git a/tools/GoogleFormat.xml b/tools/GoogleFormat.xml
index 8062246..2c65b16 100644
--- a/tools/GoogleFormat.xml
+++ b/tools/GoogleFormat.xml
@@ -45,7 +45,7 @@
 <setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
 <setting id="org.eclipse.jdt.core.formatter.insert_space_before_binary_operator" value="insert"/>
 <setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
-<setting id="org.eclipse.jdt.core.compiler.source" value="1.7"/>
+<setting id="org.eclipse.jdt.core.compiler.source" value="1.8"/>
 <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments" value="insert"/>
 <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration" value="do not insert"/>
 <setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments" value="insert"/>
@@ -156,7 +156,7 @@
 <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference" value="do not insert"/>
 <setting id="org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter" value="insert"/>
 <setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression" value="16"/>
-<setting id="org.eclipse.jdt.core.compiler.compliance" value="1.7"/>
+<setting id="org.eclipse.jdt.core.compiler.compliance" value="1.8"/>
 <setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
 <setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression" value="do not insert"/>
 <setting id="org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration" value="insert"/>
@@ -227,7 +227,7 @@
 <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws" value="insert"/>
 <setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="16"/>
 <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.compiler.codegen.targetPlatform" value="1.7"/>
+<setting id="org.eclipse.jdt.core.compiler.codegen.targetPlatform" value="1.8"/>
 <setting id="org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations" value="false"/>
 <setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_member" value="insert"/>
 <setting id="org.eclipse.jdt.core.formatter.comment.format_header" value="true"/>
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
index e69de29..bfbbd21 100644
--- a/tools/bzl/BUILD
+++ b/tools/bzl/BUILD
@@ -0,0 +1,6 @@
+
+exports_files([
+  "license-map.py",
+  "test_empty.sh",
+  "test_license.sh",
+])
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
new file mode 100644
index 0000000..326e51f0
--- /dev/null
+++ b/tools/bzl/asciidoc.bzl
@@ -0,0 +1,329 @@
+def documentation_attributes():
+  return [
+    "toc",
+    'newline="\\n"',
+    'asterisk="&#42;"',
+    'plus="&#43;"',
+    'caret="&#94;"',
+    'startsb="&#91;"',
+    'endsb="&#93;"',
+    'tilde="&#126;"',
+    "last-update-label!",
+    "source-highlighter=prettify",
+    "stylesheet=DEFAULT",
+    "linkcss=true",
+    "prettifydir=.",
+    # Just a placeholder, will be filled in asciidoctor java binary:
+    "revnumber=%s",
+  ]
+
+
+def release_notes_attributes():
+  return [
+    'toc',
+    'newline="\\n"',
+    'asterisk="&#42;"',
+    'plus="&#43;"',
+    'caret="&#94;"',
+    'startsb="&#91;"',
+    'endsb="&#93;"',
+    'tilde="&#126;"',
+    'last-update-label!',
+    'stylesheet=DEFAULT',
+    'linkcss=true',
+  ]
+
+
+def _replace_macros_impl(ctx):
+  cmd = [
+    ctx.file._exe.path,
+    '--suffix', ctx.attr.suffix,
+    "-s", ctx.file.src.path,
+    "-o", ctx.outputs.out.path,
+  ]
+  if ctx.attr.searchbox:
+    cmd.append('--searchbox')
+  else:
+    cmd.append('--no-searchbox')
+  ctx.action(
+    inputs = [ctx.file._exe, ctx.file.src],
+    outputs = [ctx.outputs.out],
+    command = cmd,
+    progress_message = "Replacing macros in %s" % ctx.file.src.short_path,
+  )
+
+_replace_macros = rule(
+  implementation = _replace_macros_impl,
+  attrs = {
+    "_exe": attr.label(
+      default = Label("//Documentation:replace_macros.py"),
+      allow_single_file = True,
+    ),
+    "src": attr.label(
+      mandatory = True,
+      allow_single_file = [".txt"],
+    ),
+    "suffix": attr.string(mandatory = True),
+    "searchbox": attr.bool(default = True),
+    "out": attr.output(mandatory = True),
+  },
+)
+
+
+def _generate_asciidoc_args(ctx):
+  args = []
+  if ctx.attr.backend:
+    args.extend(["-b", ctx.attr.backend])
+  revnumber = False
+  for attribute in ctx.attr.attributes:
+    if attribute.startswith("revnumber="):
+      revnumber = True
+    else:
+      args.extend(["-a", attribute])
+  if revnumber:
+    args.extend([
+      "--revnumber-file", ctx.file.version.path,
+    ])
+  for src in ctx.files.srcs:
+    args.append(src.path)
+  return args
+
+
+def _invoke_replace_macros(name, src, suffix, searchbox):
+  fn = src
+  if fn.startswith(":"):
+    fn = src[1:]
+
+  _replace_macros(
+    name = "macros_%s_%s" % (name, fn),
+    src = src,
+    out = fn + suffix,
+    suffix = suffix,
+    searchbox = searchbox,
+  )
+
+  return ":" + fn + suffix, fn.replace(".txt", ".html")
+
+
+def _asciidoc_impl(ctx):
+  args = [
+    "--bazel",
+    "--in-ext", ".txt" + ctx.attr.suffix,
+    "--out-ext", ".html",
+  ]
+  args.extend(_generate_asciidoc_args(ctx))
+  ctx.action(
+    inputs = ctx.files.srcs + [ctx.executable._exe, ctx.file.version],
+    outputs = ctx.outputs.outs,
+    executable = ctx.executable._exe,
+    arguments = args,
+    progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
+  )
+
+_asciidoc_attrs = {
+  "_exe": attr.label(
+    default = Label("//lib/asciidoctor:asciidoc"),
+    cfg = "host",
+    allow_files = True,
+    executable = True,
+  ),
+  "srcs": attr.label_list(mandatory = True, allow_files = True),
+  "version": attr.label(
+    default = Label("//:version.txt"),
+    allow_single_file = True,
+  ),
+  "suffix": attr.string(mandatory = True),
+  "backend": attr.string(),
+  "attributes": attr.string_list(),
+}
+
+_asciidoc = rule(
+  implementation = _asciidoc_impl,
+  attrs = _asciidoc_attrs + {
+    "outs": attr.output_list(mandatory = True),
+  },
+)
+
+
+def _genasciidoc_htmlonly(
+    name,
+    srcs = [],
+    attributes = [],
+    backend = None,
+    searchbox = True,
+    **kwargs):
+  SUFFIX = "." + name + "_macros"
+  new_srcs = []
+  outs = ["asciidoctor.css"]
+
+  for src in srcs:
+    new_src, html_name = _invoke_replace_macros(name, src, SUFFIX, searchbox)
+    new_srcs.append(new_src)
+    outs.append(html_name)
+
+  _asciidoc(
+    name = name + "_gen",
+    srcs = new_srcs,
+    suffix = SUFFIX,
+    backend = backend,
+    attributes = attributes,
+    outs = outs,
+  )
+
+  native.filegroup(
+    name = name,
+    data = outs,
+    **kwargs
+  )
+
+
+def genasciidoc(
+    name,
+    srcs = [],
+    attributes = [],
+    backend = None,
+    searchbox = True,
+    resources = True,
+    **kwargs):
+  SUFFIX = "_htmlonly"
+
+  _genasciidoc_htmlonly(
+    name = name + SUFFIX if resources else name,
+    srcs = srcs,
+    attributes = attributes,
+    backend = backend,
+    searchbox = searchbox,
+    **kwargs
+  )
+
+  if resources:
+    htmlonly = ":" + name + SUFFIX
+    native.filegroup(
+      name = name,
+      srcs = [
+        htmlonly,
+        "//Documentation:resources",
+      ],
+      **kwargs
+    )
+
+
+def _asciidoc_html_zip_impl(ctx):
+  args = [
+    "--mktmp",
+    "-z", ctx.outputs.out.path,
+    "--in-ext", ".txt" + ctx.attr.suffix,
+    "--out-ext", ".html",
+  ]
+  args.extend(_generate_asciidoc_args(ctx))
+  ctx.action(
+    inputs = ctx.files.srcs + [ctx.executable._exe, ctx.file.version],
+    outputs = [ctx.outputs.out],
+    executable = ctx.executable._exe,
+    arguments = args,
+    progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
+  )
+
+_asciidoc_html_zip = rule(
+  implementation = _asciidoc_html_zip_impl,
+  attrs = _asciidoc_attrs,
+  outputs = {
+    "out": "%{name}.zip",
+  }
+)
+
+
+def _genasciidoc_htmlonly_zip(
+    name,
+    srcs = [],
+    attributes = [],
+    backend = None,
+    searchbox = True,
+    **kwargs):
+  SUFFIX = "." + name + "_expn"
+  new_srcs = []
+
+  for src in srcs:
+    new_src, _ = _invoke_replace_macros(name, src, SUFFIX, searchbox)
+    new_srcs.append(new_src)
+
+  _asciidoc_html_zip(
+    name = name,
+    srcs = new_srcs,
+    suffix = SUFFIX,
+    backend = backend,
+    attributes = attributes,
+  )
+
+
+def _asciidoc_zip_impl(ctx):
+  tmpdir = ctx.outputs.out.path + "_tmpdir"
+  cmd = [
+    "p=$PWD",
+    "rm -rf %s" % tmpdir,
+    "mkdir %s" % tmpdir,
+    "unzip -q %s -d %s/%s/" % (ctx.file.src.path, tmpdir, ctx.attr.directory),
+  ]
+  for r in ctx.files.resources:
+    if r.path == r.short_path:
+      cmd.append("tar -cf- %s | tar -C %s -xf-" % (r.short_path, tmpdir))
+    else:
+      parent = r.path[:-len(r.short_path)]
+      cmd.append(
+        "tar -C %s -cf- %s | tar -C %s -xf-" % (parent, r.short_path, tmpdir))
+  cmd.extend([
+    "cd %s" % tmpdir,
+    "zip -qr $p/%s *" % ctx.outputs.out.path,
+  ])
+  ctx.action(
+    inputs = [ctx.file.src] + ctx.files.resources,
+    outputs = [ctx.outputs.out],
+    command = " && ".join(cmd),
+    progress_message =
+        "Generating asciidoctor zip file %s" % ctx.outputs.out.short_path,
+  )
+
+_asciidoc_zip = rule(
+  implementation = _asciidoc_zip_impl,
+  attrs = {
+    "src": attr.label(
+      mandatory = True,
+      allow_single_file = [".zip"],
+    ),
+    "resources": attr.label_list(mandatory = True, allow_files = True),
+    "directory": attr.string(mandatory = True),
+  },
+  outputs = {
+    "out": "%{name}.zip",
+  }
+)
+
+
+def genasciidoc_zip(
+    name,
+    srcs = [],
+    attributes = [],
+    directory = None,
+    backend = None,
+    searchbox = True,
+    resources = True,
+    **kwargs):
+  SUFFIX = "_htmlonly"
+
+  _genasciidoc_htmlonly_zip(
+    name = name + SUFFIX if resources else name,
+    srcs = srcs,
+    attributes = attributes,
+    backend = backend,
+    searchbox = searchbox,
+    **kwargs
+  )
+
+  if resources:
+    htmlonly = ":" + name + SUFFIX
+    _asciidoc_zip(
+      name = name,
+      src = htmlonly,
+      resources = ["//Documentation:resources"],
+      directory = directory,
+    )
diff --git a/tools/bzl/genrule2.bzl b/tools/bzl/genrule2.bzl
index e67ee30..563a9ef 100644
--- a/tools/bzl/genrule2.bzl
+++ b/tools/bzl/genrule2.bzl
@@ -15,15 +15,13 @@
 # Syntactic sugar for native genrule() rule:
 #   expose ROOT shell variable
 #   expose TMP shell variable
-#   accept single output
 
-def genrule2(out, cmd, **kwargs):
+def genrule2(cmd, **kwargs):
   cmd = ' && '.join([
     'ROOT=$$PWD',
-    'TMP=$$(mktemp -d)',
+    'TMP=$$(mktemp -d || mktemp -d -t bazel-tmp)',
     '(' + cmd + ')',
   ])
   native.genrule(
     cmd = cmd,
-    outs = [out],
     **kwargs)
diff --git a/tools/bzl/gwt.bzl b/tools/bzl/gwt.bzl
index 29987ef..7eff506 100644
--- a/tools/bzl/gwt.bzl
+++ b/tools/bzl/gwt.bzl
@@ -12,17 +12,275 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# GWT Rules Skylark rules for building [GWT](http://www.gwtproject.org/)
-# modules using Bazel.
+# Port of Buck native gwt_binary() rule. See discussion in context of
+# https://github.com/facebook/buck/issues/109
+load('//tools/bzl:genrule2.bzl', 'genrule2')
 load('//tools/bzl:java.bzl', 'java_library2')
 
+jar_filetype = FileType(['.jar'])
+
+BROWSERS = [
+  'chrome',
+  'firefox',
+  'gecko1_8',
+  'safari',
+  'msie', 'ie8', 'ie9', 'ie10',
+  'edge',
+]
+ALIASES = {
+  'chrome': 'safari',
+  'firefox': 'gecko1_8',
+  'msie': 'ie10',
+  'edge': 'gecko1_8',
+}
+
+MODULE = 'com.google.gerrit.GerritGwtUI'
+
+GWT_COMPILER = "com.google.gwt.dev.Compiler"
+
+GWT_JVM_ARGS = ['-Xmx512m']
+
+GWT_COMPILER_ARGS = [
+  '-XdisableClassMetadata',
+]
+
+GWT_COMPILER_ARGS_RELEASE_MODE = GWT_COMPILER_ARGS + [
+  '-XdisableCastChecking',
+]
+
+GWT_TRANSITIVE_DEPS = [
+  '//lib/gwt:ant',
+  '//lib/gwt:colt',
+  '//lib/gwt:javax-validation',
+  '//lib/gwt:javax-validation_src',
+  '//lib/gwt:jsinterop-annotations',
+  '//lib/gwt:jsinterop-annotations_src',
+  '//lib/gwt:tapestry',
+  '//lib/gwt:w3c-css-sac',
+  '//lib/ow2:ow2-asm',
+  '//lib/ow2:ow2-asm-analysis',
+  '//lib/ow2:ow2-asm-commons',
+  '//lib/ow2:ow2-asm-tree',
+  '//lib/ow2:ow2-asm-util',
+]
+
+DEPS = GWT_TRANSITIVE_DEPS + [
+  '//gerrit-gwtexpui:CSS',
+  '//lib:gwtjsonrpc',
+  '//lib/gwt:dev',
+  '@jgit_src//file',
+]
+
+USER_AGENT_XML = """<module rename-to='gerrit_ui'>
+<inherits name='%s'/>
+<set-property name='user.agent' value='%s'/>
+<set-property name='locale' value='default'/>
+</module>
+"""
+
 def gwt_module(gwt_xml=None, resources=[], srcs=[], **kwargs):
   if gwt_xml:
     resources += [gwt_xml]
-  if srcs:
-    resources += srcs
 
   java_library2(
     srcs = srcs,
     resources = resources,
     **kwargs)
+
+def _gwt_user_agent_module(ctx):
+  """Generate user agent specific GWT module."""
+  if not ctx.attr.user_agent:
+    return None
+
+  ua = ctx.attr.user_agent
+  impl = ua
+  if ua in ALIASES:
+    impl = ALIASES[ua]
+
+  # intermediate artifact: user agent speific GWT xml file
+  gwt_user_agent_xml = ctx.new_file(ctx.label.name + "_gwt.xml")
+  ctx.file_action(output = gwt_user_agent_xml,
+                  content=USER_AGENT_XML % (MODULE, impl))
+
+  # intermediate artifact: user agent specific zip with GWT module
+  gwt_user_agent_zip = ctx.new_file(ctx.label.name + "_gwt.zip")
+  gwt = '%s_%s.gwt.xml' % (MODULE.replace('.', '/'), ua)
+  dir = gwt_user_agent_zip.path + ".dir"
+  cmd = " && ".join([
+    "p=$PWD",
+    "mkdir -p %s" % dir,
+    "cd %s" % dir,
+    "mkdir -p $(dirname %s)" % gwt,
+    "cp $p/%s %s" % (gwt_user_agent_xml.path, gwt),
+    "$p/%s cC $p/%s $(find . | sed 's|^./||')" % (ctx.executable._zip.path, gwt_user_agent_zip.path)
+  ])
+  ctx.action(
+    inputs = [gwt_user_agent_xml] + ctx.files._zip,
+    outputs = [gwt_user_agent_zip],
+    command = cmd,
+    mnemonic = "GenerateUserAgentGWTModule")
+
+  return struct(
+    zip=gwt_user_agent_zip,
+    module=MODULE + '_' + ua
+  )
+
+def _gwt_binary_impl(ctx):
+  module = MODULE
+  output_zip = ctx.outputs.output
+  output_dir = output_zip.path + '.gwt_output'
+  deploy_dir = output_zip.path + '.gwt_deploy'
+
+  deps = _get_transitive_closure(ctx)
+
+  paths = []
+  for dep in deps:
+    paths.append(dep.path)
+
+  gwt_user_agent_modules = []
+  ua = _gwt_user_agent_module(ctx)
+  if ua:
+    paths.append(ua.zip.path)
+    gwt_user_agent_modules.append(ua.zip)
+    module = ua.module
+
+  cmd = "external/local_jdk/bin/java %s -Dgwt.normalizeTimestamps=true -cp %s %s -war %s -deploy %s " % (
+    " ".join(ctx.attr.jvm_args),
+    ":".join(paths),
+    GWT_COMPILER,
+    output_dir,
+    deploy_dir,
+  )
+  # TODO(davido): clean up command concatenation
+  cmd += " ".join([
+    "-style %s" % ctx.attr.style,
+    "-optimize %s" % ctx.attr.optimize,
+    "-strict",
+    " ".join(ctx.attr.compiler_args),
+    module + "\n",
+    "rm -rf %s/gwt-unitCache\n" % output_dir,
+    "root=`pwd`\n",
+    "cd %s; $root/%s Cc ../%s $(find .)\n" % (
+      output_dir,
+      ctx.executable._zip.path,
+      output_zip.basename,
+    )
+  ])
+
+  ctx.action(
+    inputs = list(deps) + ctx.files._jdk + ctx.files._zip + gwt_user_agent_modules,
+    outputs = [output_zip],
+    mnemonic = "GwtBinary",
+    progress_message = "GWT compiling " + output_zip.short_path,
+    command = "set -e\n" + cmd,
+  )
+
+def _get_transitive_closure(ctx):
+  deps = set()
+  for dep in ctx.attr.module_deps:
+    deps += dep.java.transitive_runtime_deps
+    deps += dep.java.transitive_source_jars
+  for dep in ctx.attr.deps:
+    if hasattr(dep, 'java'):
+      deps += dep.java.transitive_runtime_deps
+    elif hasattr(dep, 'files'):
+      deps += dep.files
+
+  return deps
+
+gwt_binary = rule(
+  implementation = _gwt_binary_impl,
+  attrs = {
+    "user_agent": attr.string(),
+    "style": attr.string(default = "OBF"),
+    "optimize": attr.string(default = "9"),
+    "deps": attr.label_list(allow_files=jar_filetype),
+    "module_deps": attr.label_list(allow_files=jar_filetype),
+    "compiler_args": attr.string_list(),
+    "jvm_args": attr.string_list(),
+    "_jdk": attr.label(
+      default=Label("//tools/defaults:jdk")),
+    "_zip": attr.label(
+      default=Label("@bazel_tools//tools/zip:zipper"),
+      cfg = "host",
+      executable=True,
+      single_file=True),
+  },
+  outputs = {
+    "output": "%{name}.zip",
+  },
+)
+
+def gwt_genrule(suffix = ""):
+  dbg = 'ui_dbg' + suffix
+  opt = 'ui_opt' + suffix
+  module_dep = ':ui_module' + suffix
+  args = GWT_COMPILER_ARGS_RELEASE_MODE if suffix == "_r" else GWT_COMPILER_ARGS
+
+  genrule2(
+    name = 'ui_optdbg' + suffix,
+    srcs = [
+      ':' + dbg,
+      ':' + opt,
+     ],
+    cmd = 'cd $$TMP;' +
+      'unzip -q $$ROOT/$(location :%s);' % dbg +
+      'mv' +
+      ' gerrit_ui/gerrit_ui.nocache.js' +
+      ' gerrit_ui/dbg_gerrit_ui.nocache.js;' +
+      'unzip -qo $$ROOT/$(location :%s);' % opt +
+      'mkdir -p $$(dirname $@);' +
+      'zip -qr $$ROOT/$@ .',
+    outs = ['ui_optdbg' + suffix + '.zip'],
+    visibility = ['//visibility:public'],
+   )
+
+  gwt_binary(
+    name = opt,
+    module_deps = [module_dep],
+    deps = DEPS,
+    compiler_args = args,
+    jvm_args = GWT_JVM_ARGS,
+  )
+
+  gwt_binary(
+    name = dbg,
+    style = 'PRETTY',
+    optimize = "0",
+    module_deps = [module_dep],
+    deps = DEPS,
+    compiler_args = GWT_COMPILER_ARGS,
+    jvm_args = GWT_JVM_ARGS,
+  )
+
+def gen_ui_module(name, suffix = ""):
+  gwt_module(
+    name = name + suffix,
+    srcs = native.glob(['src/main/java/**/*.java']),
+    gwt_xml = 'src/main/java/%s.gwt.xml' % MODULE.replace('.', '/'),
+    resources = native.glob(
+        ['src/main/java/**/*'],
+        exclude = ['src/main/java/**/*.java'] +
+        ['src/main/java/%s.gwt.xml' % MODULE.replace('.', '/')]),
+    deps = [
+      '//gerrit-gwtui-common:diffy_logo',
+      '//gerrit-gwtui-common:client',
+      '//gerrit-gwtexpui:CSS',
+      '//lib/codemirror:codemirror' + suffix,
+      '//lib/gwt:user',
+    ],
+    visibility = ['//visibility:public'],
+  )
+
+def gwt_user_agent_permutations():
+  for ua in BROWSERS:
+    gwt_binary(
+      name = "ui_%s" % ua,
+      user_agent = ua,
+      style = 'PRETTY',
+      optimize = "0",
+      module_deps = [':ui_module'],
+      deps = DEPS,
+      compiler_args = GWT_COMPILER_ARGS,
+      jvm_args = GWT_JVM_ARGS,
+    )
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
new file mode 100644
index 0000000..dcaa611
--- /dev/null
+++ b/tools/bzl/javadoc.bzl
@@ -0,0 +1,76 @@
+# Copyright (C) 2016 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.
+
+# Javadoc rule.
+
+def _impl(ctx):
+  zip_output = ctx.outputs.zip
+
+  transitive_jar_set = set()
+  source_jars = set()
+  for l in ctx.attr.libs:
+    source_jars += l.java.source_jars
+    transitive_jar_set += l.java.transitive_deps
+
+  transitive_jar_paths = [j.path for j in transitive_jar_set]
+  dir = ctx.outputs.zip.path + ".dir"
+  source = ctx.outputs.zip.path + ".source"
+  external_docs = ["http://docs.oracle.com/javase/8/docs/api"] + ctx.attr.external_docs
+  cmd = [
+      "rm -rf %s" % source,
+      "mkdir %s" % source,
+      " && ".join(["unzip -qud %s %s" % (source, j.path) for j in source_jars]),
+      "rm -rf %s" % dir,
+      "mkdir %s" % dir,
+      " ".join([
+        ctx.file._javadoc.path,
+        "-Xdoclint:-missing",
+        "-protected",
+        "-encoding UTF-8",
+        "-charset UTF-8",
+        "-notimestamp",
+        "-quiet",
+        "-windowtitle '%s'" % ctx.attr.title,
+        " ".join(['-link %s' % url for url in external_docs]),
+        "-sourcepath %s" % source,
+        "-subpackages ",
+        ":".join(ctx.attr.pkgs),
+        " -classpath ",
+        ":".join(transitive_jar_paths),
+        "-d %s" % dir]),
+    "find %s -exec touch -t 198001010000 '{}' ';'" % dir,
+    "(cd %s && zip -qr ../%s *)" % (dir, ctx.outputs.zip.basename),
+  ]
+  ctx.action(
+      inputs = list(transitive_jar_set) + list(source_jars) + ctx.files._jdk,
+      outputs = [zip_output],
+      command = " && ".join(cmd))
+
+java_doc = rule(
+    attrs = {
+      "libs": attr.label_list(allow_files = False),
+      "pkgs": attr.string_list(),
+      "title": attr.string(),
+      "external_docs": attr.string_list(),
+      "_javadoc": attr.label(
+        default = Label("@local_jdk//:bin/javadoc"),
+        single_file = True,
+        allow_files = True),
+      "_jdk": attr.label(
+        default = Label("@local_jdk//:jdk-default"),
+        allow_files = True),
+    },
+    implementation = _impl,
+    outputs = {"zip" : "%{name}.zip"},
+)
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
new file mode 100644
index 0000000..d603f8f
--- /dev/null
+++ b/tools/bzl/js.bzl
@@ -0,0 +1,368 @@
+NPMJS = "NPMJS"
+GERRIT = "GERRIT"
+
+NPM_VERSIONS = {
+  "bower":   '1.7.9',
+  'crisper': '2.0.2',
+  'vulcanize': '1.14.8',
+}
+
+NPM_SHA1S = {
+  "bower":  'b7296c2393e0d75edaa6ca39648132dd255812b0',
+  "crisper": '7183c58cea33632fb036c91cefd1b43e390d22a2',
+  'vulcanize': '679107f251c19ab7539529b1e3fdd40829e6fc63',
+}
+
+
+def _npm_tarball(name):
+  return "%s@%s.npm_binary.tgz" % (name, NPM_VERSIONS[name])
+
+
+def _npm_binary_impl(ctx):
+  """rule to download a NPM archive."""
+  name = ctx.name
+  version= NPM_VERSIONS[name]
+  sha1 = NPM_VERSIONS[name]
+
+  dir = '%s-%s' % (name, version)
+  filename = '%s.tgz' % dir
+  base =  '%s@%s.npm_binary.tgz' % (name, version)
+  dest = ctx.path(base)
+  repository = ctx.attr.repository
+  if repository == GERRIT:
+    url = 'http://gerrit-maven.storage.googleapis.com/npm-packages/%s' % filename
+  elif repository == NPMJS:
+    url = 'http://registry.npmjs.org/%s/-/%s' % (name, filename)
+  else:
+    fail('repository %s not in {%s,%s}' % (repository, GERRIT, NPMJS))
+
+  python = ctx.which("python")
+  script = ctx.path(ctx.attr._download_script)
+
+  sha1 = NPM_SHA1S[name]
+  args = [python, script, "-o", dest, "-u", url, "-v", sha1]
+  out = ctx.execute(args)
+  if out.return_code:
+    fail("failed %s: %s" % (args, out.stderr))
+  ctx.file("BUILD", "package(default_visibility=['//visibility:public'])\nfilegroup(name='tarball', srcs=['%s'])" % base, False)
+
+npm_binary = repository_rule(
+    implementation=_npm_binary_impl,
+    local=True,
+    attrs={
+      # Label resolves within repo of the .bzl file.
+      "_download_script": attr.label(default=Label("//tools:download_file.py")),
+      "repository": attr.string(default=NPMJS),
+    })
+
+
+# for use in repo rules.
+def _run_npm_binary_str(ctx, tarball, args):
+  python_bin = ctx.which("python")
+  return " ".join([
+    python_bin,
+    ctx.path(ctx.attr._run_npm),
+    ctx.path(tarball)] + args)
+
+
+def _bower_archive(ctx):
+  """Download a bower package."""
+  download_name = '%s__download_bower.zip' % ctx.name
+  renamed_name = '%s__renamed.zip' % ctx.name
+  version_name = '%s__version.json' % ctx.name
+
+  cmd = [
+      ctx.which("python"),
+      ctx.path(ctx.attr._download_bower),
+      '-b', '%s' % _run_npm_binary_str(ctx, ctx.attr._bower_archive, []),
+      '-n', ctx.name,
+      '-p', ctx.attr.package,
+      '-v', ctx.attr.version,
+      '-s', ctx.attr.sha1,
+      '-o', download_name,
+    ]
+
+  out = ctx.execute(cmd)
+  if out.return_code:
+    fail("failed %s: %s" % (" ".join(cmd), out.stderr))
+
+  _bash(ctx, " && " .join([
+    "TMP=$(mktemp -d || mktemp -d -t bazel-tmp)",
+    "cd $TMP",
+    "mkdir bower_components",
+    "cd bower_components",
+    "unzip %s" % ctx.path(download_name),
+    "cd ..",
+    "zip -r %s bower_components" % renamed_name,]))
+
+  dep_version = ctx.attr.semver if ctx.attr.semver else ctx.attr.version
+  ctx.file(version_name,
+           '"%s":"%s#%s"' % (ctx.name, ctx.attr.package, dep_version))
+  ctx.file(
+    "BUILD",
+    "\n".join([
+      "package(default_visibility=['//visibility:public'])",
+      "filegroup(name = 'zipfile', srcs = ['%s'], )" % download_name,
+      "filegroup(name = 'version_json', srcs = ['%s'], visibility=['//visibility:public'])" % version_name,
+    ]), False)
+
+
+def _bash(ctx, cmd):
+  cmd_list = ["/bin/bash", "-c", cmd]
+  out = ctx.execute(cmd_list)
+  if out.return_code:
+    fail("failed %s: %s" % (" ".join(cmd_list), out.stderr))
+
+
+bower_archive=repository_rule(
+  _bower_archive,
+  attrs={
+    "_bower_archive": attr.label(default=Label("@bower//:%s" % _npm_tarball("bower"))),
+    "_run_npm": attr.label(default=Label("//tools/js:run_npm_binary.py")),
+    "_download_bower": attr.label(default=Label("//tools/js:download_bower.py")),
+    "sha1": attr.string(mandatory=True),
+    "version": attr.string(mandatory=True),
+    "package": attr.string(mandatory=True),
+    "semver": attr.string(),
+  })
+
+
+def _bower_component_impl(ctx):
+  transitive_zipfiles = set([ctx.file.zipfile])
+  for d in ctx.attr.deps:
+    transitive_zipfiles += d.transitive_zipfiles
+
+  transitive_licenses = set()
+  if ctx.file.license:
+    transitive_licenses += set([ctx.file.license])
+
+  for d in ctx.attr.deps:
+    transitive_licenses += d.transitive_licenses
+
+  transitive_versions = set(ctx.files.version_json)
+  for d in ctx.attr.deps:
+    transitive_versions += d.transitive_versions
+
+  return struct(
+    transitive_zipfiles=transitive_zipfiles,
+    transitive_versions=transitive_versions,
+    transitive_licenses=transitive_licenses,
+  )
+
+
+_common_attrs = {
+    "deps": attr.label_list(providers=[
+      "transitive_zipfiles",
+      "transitive_versions",
+      "transitive_licenses",
+    ])
+  }
+
+
+def _js_component(ctx):
+  dir = ctx.outputs.zip.path + ".dir"
+  name = ctx.outputs.zip.basename
+  if name.endswith(".zip"):
+    name = name[:-4]
+  dest = "%s/%s" % (dir, name)
+  cmd = " && ".join([
+    "mkdir -p %s" % dest,
+    "cp %s %s/" % (' '.join([s.path for s in ctx.files.srcs]), dest),
+    "cd %s" % dir,
+    "find . -exec touch -t 198001010000 '{}' ';'",
+    "zip -qr ../%s *" %  ctx.outputs.zip.basename
+  ])
+
+  ctx.action(
+    inputs = ctx.files.srcs,
+    outputs = [ctx.outputs.zip],
+    command = cmd,
+    mnemonic = "GenBowerZip")
+
+  licenses = set()
+  if ctx.file.license:
+    licenses += set([ctx.file.license])
+
+  return struct(
+    transitive_zipfiles=list([ctx.outputs.zip]),
+    transitive_versions=set([]),
+    transitive_licenses=licenses)
+
+
+js_component = rule(
+  _js_component,
+  attrs=_common_attrs + {
+    "srcs": attr.label_list(allow_files=[".js"]),
+    "license": attr.label(allow_single_file=True),
+  },
+  outputs={
+    "zip": "%{name}.zip",
+  }
+)
+
+
+_bower_component = rule(
+  _bower_component_impl,
+  attrs=_common_attrs + {
+    "zipfile": attr.label(allow_single_file=[".zip"]),
+    "license": attr.label(allow_single_file=True),
+    "version_json": attr.label(allow_files=[".json"]),
+
+    # If set, define by hand, and don't regenerate this entry in bower2bazel.
+    "seed": attr.bool(default=False)
+  })
+
+
+
+# TODO(hanwen): make license mandatory.
+def bower_component(name, license=None, **kwargs):
+  prefix = "//lib:LICENSE-"
+  if license and not license.startswith(prefix):
+    license = prefix + license
+  _bower_component(
+    name=name,
+    license=license,
+    zipfile="@%s//:zipfile"% name,
+    version_json="@%s//:version_json" % name,
+    **kwargs)
+
+
+def _bower_component_bundle_impl(ctx):
+  """A bunch of bower components zipped up."""
+  zips = set([])
+  for d in ctx.attr.deps:
+    zips += d.transitive_zipfiles
+
+  versions = set([])
+  for d in ctx.attr.deps:
+    versions += d.transitive_versions
+
+  licenses = set([])
+  for d in ctx.attr.deps:
+    licenses += d.transitive_versions
+
+  out_zip = ctx.outputs.zip
+  out_versions = ctx.outputs.version_json
+
+  ctx.action(
+    inputs=list(zips),
+    outputs=[out_zip],
+    command=" && ".join([
+      "p=$PWD",
+      "rm -rf %s.dir" % out_zip.path,
+      "mkdir -p %s.dir/bower_components" % out_zip.path,
+      "cd %s.dir/bower_components" % out_zip.path,
+      "for z in %s; do unzip -q $p/$z ; done" % " ".join(sorted([z.path for z in zips])),
+      "cd ..",
+      "find . -exec touch -t 198001010000 '{}' ';'",
+      "zip -qr $p/%s bower_components/*" % out_zip.path,
+    ]),
+    mnemonic="BowerCombine")
+
+  ctx.action(
+    inputs=list(versions),
+    outputs=[out_versions],
+    mnemonic="BowerVersions",
+    command="(echo '{' ; for j in  %s ; do cat $j; echo ',' ; done ; echo \\\"\\\":\\\"\\\"; echo '}') > %s" % (" ".join([v.path for v in versions]), out_versions.path))
+
+  return struct(
+    transitive_zipfiles=zips,
+    transitive_versions=versions,
+    transitive_licenses=licenses)
+
+
+bower_component_bundle = rule(
+  _bower_component_bundle_impl,
+  attrs=_common_attrs,
+  outputs={
+    "zip": "%{name}.zip",
+    "version_json": "%{name}-versions.json",
+  }
+)
+
+def _vulcanize_impl(ctx):
+  # intermediate artifact.
+  vulcanized = ctx.new_file(
+    ctx.configuration.genfiles_dir, ctx.outputs.html, ".vulcanized.html")
+  destdir = ctx.outputs.html.path + ".dir"
+  zips =  [z for d in ctx.attr.deps for z in d.transitive_zipfiles ]
+
+  hermetic_npm_binary = " ".join([
+    'python',
+    "$p/" + ctx.file._run_npm.path,
+    "$p/" + ctx.file._vulcanize_archive.path,
+    '--inline-scripts',
+    '--inline-css',
+    '--strip-comments',
+    '--out-html', "$p/" + vulcanized.path,
+    ctx.file.app.path
+  ])
+
+  pkg_dir = ctx.attr.pkg.lstrip("/")
+  cmd = " && ".join([
+    # unpack dependencies.
+    "export PATH",
+    "p=$PWD",
+    "rm -rf %s" % destdir,
+    "mkdir -p %s/%s/bower_components" % (destdir, pkg_dir),
+    "for z in %s; do unzip -qd %s/%s/bower_components/ $z; done" % (
+      ' '.join([z.path for z in zips]), destdir, pkg_dir),
+    "tar -cf - %s | tar -C %s -xf -" % (" ".join([s.path for s in ctx.files.srcs]), destdir),
+    "cd %s" % destdir,
+    hermetic_npm_binary,
+  ])
+  ctx.action(
+    mnemonic = "Vulcanize",
+    inputs = [ctx.file._run_npm, ctx.file.app,
+              ctx.file._vulcanize_archive
+    ] + list(zips) + ctx.files.srcs,
+    outputs = [vulcanized],
+    command = cmd)
+
+  hermetic_npm_command = "export PATH && " + " ".join([
+    'python',
+    ctx.file._run_npm.path,
+    ctx.file._crisper_archive.path,
+    "--always-write-script",
+    "--source", vulcanized.path,
+    "--html", ctx.outputs.html.path,
+    "--js", ctx.outputs.js.path])
+
+  ctx.action(
+    mnemonic = "Crisper",
+    inputs = [ctx.file._run_npm, ctx.file.app,
+              ctx.file._crisper_archive, vulcanized],
+    outputs = [ctx.outputs.js, ctx.outputs.html],
+    command = hermetic_npm_command)
+
+
+_vulcanize_rule = rule(
+  _vulcanize_impl,
+  attrs = {
+    "deps": attr.label_list(providers=["transitive_zipfiles"]),
+    "app": attr.label(mandatory=True, allow_single_file=True),
+    "srcs": attr.label_list(allow_files=[".js", ".html", ".txt", ".css", ".ico"]),
+
+    "pkg": attr.string(mandatory=True),
+    "_run_npm": attr.label(
+      default=Label("//tools/js:run_npm_binary.py"),
+      allow_single_file=True
+    ),
+    "_vulcanize_archive": attr.label(
+      default=Label("@vulcanize//:%s" % _npm_tarball("vulcanize")),
+      allow_single_file=True
+    ),
+    "_crisper_archive": attr.label(
+      default=Label("@crisper//:%s" % _npm_tarball("crisper")),
+      allow_single_file=True
+    ),
+  },
+  outputs = {
+    "html": "%{name}.html",
+    "js": "%{name}.js",
+  }
+)
+
+def vulcanize(*args, **kwargs):
+  """Vulcanize runs vulcanize and crisper on a set of sources."""
+  _vulcanize_rule(*args, pkg=PACKAGE_NAME, **kwargs)
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
new file mode 100644
index 0000000..8469f4f
--- /dev/null
+++ b/tools/bzl/license-map.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python
+
+# reads bazel query XML files, to join target names with their licenses.
+
+from __future__ import print_function
+
+import argparse
+from collections import defaultdict
+from shutil import copyfileobj
+from sys import stdout, stderr
+import xml.etree.ElementTree as ET
+
+KNOWN_PROVIDED_DEPS = [
+  "//lib/bouncycastle:bcpg",
+  "//lib/bouncycastle:bcpkix",
+  "//lib/bouncycastle:bcprov",
+]
+
+DO_NOT_DISTRIBUTE = "//lib:LICENSE-DO_NOT_DISTRIBUTE"
+
+LICENSE_PREFIX = "//lib:LICENSE-"
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--asciidoctor", action="store_true")
+parser.add_argument("xmls", nargs="+")
+args = parser.parse_args()
+
+entries = defaultdict(list)
+graph = defaultdict(list)
+handled_rules = []
+
+for xml in args.xmls:
+  tree = ET.parse(xml)
+  root = tree.getroot()
+
+  for child in root:
+    rule_name = child.attrib["name"]
+    if rule_name in handled_rules:
+      # already handled in other xml files
+      continue
+
+    handled_rules.append(rule_name)
+    for c in child.getchildren():
+      if c.tag != "rule-input":
+        continue
+
+      license_name = c.attrib["name"]
+      if LICENSE_PREFIX in license_name:
+        if rule_name in KNOWN_PROVIDED_DEPS:
+          continue
+
+        entries[rule_name].append(license_name)
+        graph[license_name].append(rule_name)
+
+if len(graph[DO_NOT_DISTRIBUTE]):
+  print("DO_NOT_DISTRIBUTE license found in:", file=stderr)
+  for target in graph[DO_NOT_DISTRIBUTE]:
+    print(target, file=stderr)
+  exit(1)
+
+if args.asciidoctor:
+  print(
+# We don't want any blank line before "= Gerrit Code Review - Licenses"
+"""= Gerrit Code Review - Licenses
+
+Gerrit open source software is licensed under the <<Apache2_0,Apache
+License 2.0>>.  Executable distributions also include other software
+components that are provided under additional licenses.
+
+[[cryptography]]
+== Cryptography Notice
+
+This distribution includes cryptographic software.  The country
+in which you currently reside may have restrictions on the import,
+possession, use, and/or re-export to another country, of encryption
+software.  BEFORE using any encryption software, please check
+your country's laws, regulations and policies concerning the
+import, possession, or use, and re-export of encryption software,
+to see if this is permitted.  See the
+link:http://www.wassenaar.org/[Wassenaar Arrangement]
+for more information.
+
+The U.S. Government Department of Commerce, Bureau of Industry
+and Security (BIS), has classified this software as Export
+Commodity Control Number (ECCN) 5D002.C.1, which includes
+information security software using or performing cryptographic
+functions with asymmetric algorithms.  The form and manner of
+this distribution makes it eligible for export under the License
+Exception ENC Technology Software Unrestricted (TSU) exception
+(see the BIS Export Administration Regulations, Section 740.13)
+for both object code and source code.
+
+Gerrit includes an SSH daemon (Apache SSHD), to support authenticated
+uploads of changes directly from `git push` command line clients.
+
+Gerrit includes an SSH client (JSch), to support authenticated
+replication of changes to remote systems, such as for automatic
+updates of mirror servers, or realtime backups.
+
+For either feature to function, Gerrit requires the
+link:http://java.sun.com/javase/technologies/security/[Java Cryptography extensions]
+and/or the
+link:http://www.bouncycastle.org/java.html[Bouncy Castle Crypto API]
+to be installed by the end-user.
+
+== Licenses
+""")
+
+  for n in sorted(graph.keys()):
+    if len(graph[n]) == 0:
+      continue
+
+    name = n[len(LICENSE_PREFIX):]
+    safename = name.replace(".", "_")
+    print()
+    print("[[%s]]" % safename)
+    print("=== " + name)
+    print()
+    for d in sorted(graph[n]):
+      if d.startswith("//lib:") or d.startswith("//lib/"):
+        p = d[len("//lib:"):]
+      else:
+        p = d[d.index(":")+1:].lower()
+      if "__" in p:
+        p = p[:p.index("__")]
+      print("* " + p)
+    print()
+    print("[[%s_license]]" % safename)
+    print("----")
+    with open(n[2:].replace(":", "/")) as fd:
+      copyfileobj(fd, stdout)
+    print()
+    print("----")
+    print()
+
+  print(
+"""
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+""")
+
+else:
+  for k, vs in sorted(entries.items()):
+    for v in vs:
+      print(k, v)
diff --git a/tools/bzl/license.bzl b/tools/bzl/license.bzl
new file mode 100644
index 0000000..60ee60b
--- /dev/null
+++ b/tools/bzl/license.bzl
@@ -0,0 +1,58 @@
+
+def normalize_target_name(target):
+  return target.replace("//", "").replace("/", "__").replace(":", "___")
+
+def license_map(name, targets = [], opts = [], **kwargs):
+  """Generate XML for all targets that depend directly on a LICENSE file"""
+  xmls = []
+  tools = [ "//tools/bzl:license-map.py", "//lib:all-licenses" ]
+  for target in targets:
+    subname = name + "_" + normalize_target_name(target) + ".xml"
+    xmls.append("$(location :%s)" % subname)
+    tools.append(subname)
+    native.genquery(
+      name = subname,
+      scope = [ target ],
+
+      # Find everything that depends on a license file, but remove
+      # the license files themselves from this list.
+      expression = 'rdeps(%s, filter("//lib:LICENSE.*", deps(%s)),1) - filter("//lib:LICENSE.*", deps(%s))' % (target, target, target),
+
+      # We are interested in the edges of the graph ({java_library,
+      # license-file} tuples).  'query' provides this in the XML output.
+      opts = [ "--output=xml", ],
+    )
+
+  # post process the XML into our favorite format.
+  native.genrule(
+    name = "gen_license_txt_" + name,
+    cmd = "python $(location //tools/bzl:license-map.py) %s %s > $@" % (" ".join(opts), " ".join(xmls)),
+    outs = [ name + ".txt" ],
+    tools = tools,
+    **kwargs
+  )
+
+def license_test(name, target):
+  """Make sure a target doesn't depend on DO_NOT_DISTRIBUTE license"""
+  txt = name + "-forbidden.txt"
+
+  # fully qualify target name.
+  if target[0] not in ":/":
+    target = ":" + target
+  if target[0] != "/":
+    target = "//" + PACKAGE_NAME + target
+
+  forbidden = "//lib:LICENSE-DO_NOT_DISTRIBUTE"
+  native.genquery(
+    name = txt,
+    scope = [ target, forbidden ],
+    # Find everything that depends on a license file, but remove
+    # the license files themselves from this list.
+    expression = 'rdeps(%s, "%s", 1) - rdeps(%s, "%s", 0)' % (target, forbidden, target, forbidden),
+  )
+  native.sh_test(
+    name = name,
+    srcs = [ "//tools/bzl:test_license.sh" ],
+    args  = [ "$(location :%s)" % txt ],
+    data = [ txt ],
+  )
diff --git a/tools/bzl/maven.bzl b/tools/bzl/maven.bzl
index ce2f483..c255c0c 100644
--- a/tools/bzl/maven.bzl
+++ b/tools/bzl/maven.bzl
@@ -18,10 +18,7 @@
   return ('$(location //tools:merge_jars) $@ '
           + ' '.join(['$(location %s)' % j for j in jars]))
 
-def merge_maven_jars(
-    name,
-    srcs,
-    visibility = []):
+def merge_maven_jars(name, srcs, **kwargs):
   native.genrule(
     name = '%s__merged_bin' % name,
     cmd = cmd(srcs),
@@ -31,5 +28,5 @@
   native.java_import(
     name = name,
     jars = [':%s__merged_bin' % name],
-    visibility = visibility,
+    **kwargs
   )
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
new file mode 100644
index 0000000..9d2b2c1
--- /dev/null
+++ b/tools/bzl/pkg_war.bzl
@@ -0,0 +1,155 @@
+# Copyright (C) 2016 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.
+
+# War packaging.
+
+jar_filetype = FileType(['.jar'])
+
+LIBS = [
+  '//gerrit-war:init',
+  '//gerrit-war:log4j-config',
+  '//gerrit-war:version',
+  '//lib:postgresql',
+  '//lib/log:impl_log4j',
+]
+
+PGMLIBS = [
+  '//gerrit-pgm:pgm'
+]
+
+def _add_context(in_file, output):
+  input_path = in_file.path
+  return [
+    'unzip -qd %s %s' % (output, input_path)
+  ]
+
+def _add_file(in_file, output):
+  output_path = output
+  input_path = in_file.path
+  short_path = in_file.short_path
+  n = in_file.basename
+
+  # TODO(davido): Drop this when provided_deps added to java_library
+  if n.find('-jdk15on-') != -1:
+    return []
+
+  if short_path.startswith('gerrit-'):
+    n = short_path.split('/')[0] + '-' + n
+
+  output_path += n
+  return [
+    'test -L %s || ln -s $(pwd)/%s %s' % (output_path, input_path, output_path)
+  ]
+
+def _make_war(input_dir, output):
+  return '(%s)' % ' && '.join([
+    'root=$(pwd)',
+    'cd %s' % input_dir,
+    "find . -exec touch -t 198001010000 '{}' ';' 2> /dev/null",
+    'zip -9qr ${root}/%s .' % (output.path),
+  ])
+
+def _war_impl(ctx):
+  war = ctx.outputs.war
+  build_output = war.path + '.build_output'
+  inputs = []
+
+  # Create war layout
+  cmd = [
+    'set -e;rm -rf ' + build_output,
+    'mkdir -p ' + build_output,
+    'mkdir -p %s/WEB-INF/lib' % build_output,
+    'mkdir -p %s/WEB-INF/pgm-lib' % build_output,
+  ]
+
+  # Add lib
+  transitive_lib_deps = set()
+  for l in ctx.attr.libs:
+    if hasattr(l, 'java'):
+      transitive_lib_deps += l.java.transitive_runtime_deps
+    elif hasattr(l, 'files'):
+      transitive_lib_deps += l.files
+
+  for dep in transitive_lib_deps:
+    cmd += _add_file(dep, build_output + '/WEB-INF/lib/')
+    inputs.append(dep)
+
+  # Add pgm lib
+  transitive_pgmlib_deps = set()
+  for l in ctx.attr.pgmlibs:
+    transitive_pgmlib_deps += l.java.transitive_runtime_deps
+
+  for dep in transitive_pgmlib_deps:
+    if dep not in inputs:
+      cmd += _add_file(dep, build_output + '/WEB-INF/pgm-lib/')
+      inputs.append(dep)
+
+  # Add context
+  transitive_context_deps = set()
+  if ctx.attr.context:
+    for jar in ctx.attr.context:
+      if hasattr(jar, 'java'):
+        transitive_context_deps += jar.java.transitive_runtime_deps
+      elif hasattr(jar, 'files'):
+        transitive_context_deps += jar.files
+  for dep in transitive_context_deps:
+    cmd += _add_context(dep, build_output)
+    inputs.append(dep)
+
+  # Add zip war
+  cmd.append(_make_war(build_output, war))
+
+  ctx.action(
+    inputs = inputs,
+    outputs = [war],
+    mnemonic = 'WAR',
+    command = '\n'.join(cmd),
+    use_default_shell_env = True,
+  )
+
+# context: go to the root directory
+# libs: go to the WEB-INF/lib directory
+# pgmlibs: go to the WEB-INF/pgm-lib directory
+_pkg_war = rule(
+  attrs = {
+    'context': attr.label_list(allow_files = True),
+    'libs': attr.label_list(allow_files = jar_filetype),
+    'pgmlibs': attr.label_list(allow_files = False),
+  },
+  implementation = _war_impl,
+  outputs = {'war' : '%{name}.war'},
+)
+
+def pkg_war(name, ui = 'ui_optdbg', context = [], doc = False, **kwargs):
+  doc_ctx = []
+  doc_lib = []
+  ui_deps = []
+  if ui == 'polygerrit' or ui == 'ui_optdbg' or ui == 'ui_optdbg_r':
+    ui_deps.append('//polygerrit-ui/app:polygerrit_ui')
+  if ui and ui != 'polygerrit':
+    ui_deps.append('//gerrit-gwtui:%s' % ui)
+  if doc:
+    doc_ctx.append('//Documentation:html')
+    doc_lib.append('//Documentation:index')
+
+  _pkg_war(
+    name = name,
+    libs = LIBS + doc_lib,
+    pgmlibs = PGMLIBS,
+    context = doc_ctx + context + ui_deps + [
+      '//gerrit-main:main_bin_deploy.jar',
+      '//gerrit-war:webapp_assets',
+    ],
+    **kwargs
+  )
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
new file mode 100644
index 0000000..32f57e86
--- /dev/null
+++ b/tools/bzl/plugin.bzl
@@ -0,0 +1,36 @@
+
+def gerrit_plugin(
+    name,
+    deps = [],
+    srcs = [],
+    resources = [],
+    manifest_entries = [],
+    **kwargs):
+  # TODO(davido): Fix stamping: run git describe in plugin directory
+  # https://github.com/bazelbuild/bazel/issues/1758
+  manifest_lines = [
+    "Gerrit-ApiType: plugin",
+    "Implementation-Version: 1.0",
+    "Implementation-Vendor: Gerrit Code Review",
+  ]
+  for line in manifest_entries:
+    manifest_lines.append(line.replace('$', '\$'))
+
+  native.java_library(
+    name = name + '__plugin',
+    srcs = srcs,
+    resources = resources,
+    deps = deps + ['//gerrit-plugin-api:lib-neverlink'],
+    visibility = ['//visibility:public'],
+  )
+
+  native.java_binary(
+    name = name,
+    deploy_manifest_lines = manifest_lines,
+    main_class = 'Dummy',
+    runtime_deps = [
+      ':%s__plugin' % name,
+    ],
+    visibility = ['//visibility:public'],
+    **kwargs
+  )
diff --git a/tools/bzl/test_empty.sh b/tools/bzl/test_empty.sh
new file mode 100755
index 0000000..0d4398d
--- /dev/null
+++ b/tools/bzl/test_empty.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+if test -s $1
+then
+    echo "$1 not empty:"
+    cat $1
+    exit 1
+fi
diff --git a/tools/bzl/test_license.sh b/tools/bzl/test_license.sh
new file mode 100755
index 0000000..6ac6dab
--- /dev/null
+++ b/tools/bzl/test_license.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+
+filtered="$1.filtered"
+
+cat $1 \
+  | grep -v "//lib/bouncycastle:bcpg" \
+  | grep -v "//lib/bouncycastle:bcpkix" \
+  | grep -v "//lib/bouncycastle:bcprov" \
+  > $filtered
+
+if test -s $filtered
+then
+  echo "$filtered not empty:"
+  cat $filtered
+  exit 1
+fi
diff --git a/tools/bzl/unsign.bzl b/tools/bzl/unsign.bzl
new file mode 100644
index 0000000..f42986a
--- /dev/null
+++ b/tools/bzl/unsign.bzl
@@ -0,0 +1,16 @@
+
+def unsign_jars(name, deps, **kwargs):
+  """unsign_jars collects its dependencies into a single java_import.
+
+  As a side effect, the signature is removed.
+  """
+  native.java_binary(
+    name = name + '-unsigned-binary',
+    runtime_deps = deps,
+    main_class = 'dummy'
+  )
+
+  native.java_import(
+    name = name,
+    jars = [ name + '-unsigned-binary_deploy.jar' ],
+    **kwargs)
diff --git a/tools/default.defs b/tools/default.defs
index 191dfe5..fb4c6de 100644
--- a/tools/default.defs
+++ b/tools/default.defs
@@ -201,7 +201,7 @@
         ':%s__gwt_application' % name +
         ';cd $TMP' +
         ';zip -qr $OUT .',
-      out = '%s-static.zip' % name,
+      out = '%s-static.jar' % name,
     )
     gwt_binary(
       name = name + '__gwt_application',
diff --git a/tools/download_file.py b/tools/download_file.py
index bd67b50..c9736bf 100755
--- a/tools/download_file.py
+++ b/tools/download_file.py
@@ -26,10 +26,6 @@
 
 GERRIT_HOME = path.expanduser('~/.gerritcodereview')
 CACHE_DIR = path.join(GERRIT_HOME, 'buck-cache', 'downloaded-artifacts')
-# LEGACY_CACHE_DIR is only used to allow existing workspaces to move already
-# downloaded files to the new cache directory.
-# Please remove after 3 months (2015-10-07).
-LEGACY_CACHE_DIR = path.join(GERRIT_HOME, 'buck-cache')
 LOCAL_PROPERTIES = 'local.properties'
 
 
@@ -78,16 +74,6 @@
   name = '%s-%s' % (path.basename(args.o), h)
   return path.join(CACHE_DIR, name)
 
-# Please remove after 3 months (2015-10-07). See LEGACY_CACHE_DIR above.
-def legacy_cache_entry(args):
-  if args.v:
-    h = args.v
-  else:
-    h = sha1(args.u.encode('utf-8')).hexdigest()
-  name = '%s-%s' % (path.basename(args.o), h)
-  return path.join(LEGACY_CACHE_DIR, name)
-
-
 opts = OptionParser()
 opts.add_option('-o', help='local output file')
 opts.add_option('-u', help='URL to download')
@@ -98,26 +84,15 @@
 args, _ = opts.parse_args()
 
 root_dir = args.o
-while root_dir:
+while root_dir and root_dir != "/":
   root_dir, n = path.split(root_dir)
   if n == 'buck-out':
     break
 
 redirects = download_properties(root_dir)
 cache_ent = cache_entry(args)
-legacy_cache_ent = legacy_cache_entry(args)
 src_url = resolve_url(args.u, redirects)
 
-# Please remove after 3 months (2015-10-07). See LEGACY_CACHE_DIR above.
-if not path.exists(cache_ent) and path.exists(legacy_cache_ent):
-  try:
-    safe_mkdirs(path.dirname(cache_ent))
-  except OSError as err:
-    print('error creating directory %s: %s' %
-          (path.dirname(cache_ent), err), file=stderr)
-    exit(1)
-  shutil.move(legacy_cache_ent, cache_ent)
-
 if not path.exists(cache_ent):
   try:
     safe_mkdirs(path.dirname(cache_ent))
diff --git a/tools/eclipse/BUCK b/tools/eclipse/BUCK
index 0bcde9d..a8b3f01 100644
--- a/tools/eclipse/BUCK
+++ b/tools/eclipse/BUCK
@@ -21,8 +21,14 @@
     '//lib/bouncycastle:bcprov',
     '//lib/bouncycastle:bcpg',
     '//lib/bouncycastle:bcpkix',
+    '//lib/gwt:ant',
+    '//lib/gwt:colt',
     '//lib/gwt:javax-validation',
     '//lib/gwt:javax-validation_src',
+    '//lib/gwt:jsinterop-annotations',
+    '//lib/gwt:jsinterop-annotations_src',
+    '//lib/gwt:tapestry',
+    '//lib/gwt:w3c-css-sac',
     '//lib/jetty:servlets',
     '//lib/prolog:compiler_lib',
     '//polygerrit-ui:polygerrit_components',
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 46f5680..96ddff1 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -28,7 +28,7 @@
 JRE = '/'.join([
   'org.eclipse.jdt.launching.JRE_CONTAINER',
   'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType',
-  'JavaSE-1.7',
+  'JavaSE-1.8',
 ])
 
 ROOT = path.abspath(__file__)
@@ -80,14 +80,14 @@
   with open(p, 'w') as fd:
     if path.exists(path.join(root, 'src', 'test', 'java')):
       testpath = """
-  <classpathentry kind="src" path="src/test/java"\
+  <classpathentry excluding="**/BUILD" kind="src" path="src/test/java"\
  out="eclipse-out/test"/>"""
     else:
       testpath = ""
     print("""\
 <?xml version="1.0" encoding="UTF-8"?>
 <classpath>
-  <classpathentry kind="src" path="src/main/java"/>%(testpath)s
+  <classpathentry excluding="**/BUILD" kind="src" path="src/main/java"/>%(testpath)s
   <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
   <classpathentry combineaccessrules="false" kind="src" path="/gerrit"/>
   <classpathentry kind="output" path="eclipse-out/classes"/>
@@ -101,6 +101,11 @@
   def classpathentry(kind, path, src=None, out=None, exported=None):
     e = doc.createElement('classpathentry')
     e.setAttribute('kind', kind)
+    # TODO(davido): Remove this and other exclude BUILD files hack
+    # when this Bazel bug is fixed:
+    # https://github.com/bazelbuild/bazel/issues/1083
+    if kind == 'src':
+      e.setAttribute('excluding', '**/BUILD')
     e.setAttribute('path', path)
     if src:
       e.setAttribute('sourcepath', src)
@@ -119,6 +124,7 @@
 
   # Classpath entries are absolute for cross-cell support
   java_library = re.compile('.*/buck-out/gen/(.*)/lib__[^/]+__output/[^/]+[.]jar$')
+  srcs = re.compile('.*/(__.*__)/.*')
   for p in _query_classpath(MAIN):
     if p.endswith('-src.jar'):
       # gwt_module() depends on -src.jar for Java to JavaScript compiles.
@@ -175,9 +181,19 @@
     for j in sorted(libs):
       s = None
       if j.endswith('.jar'):
-        s = j[:-4] + '_src.jar'
+        s = j[:-4] + '-src.jar'
         if not path.exists(s):
-          s = None
+          m = srcs.match(s)
+          if m:
+            l = m.group(1)
+            if l.endswith('__jar__'):
+              s = s.replace(l, l.replace('__jar__', '_src__'))
+            else:
+              s = s.replace(l, l[:-1] + 'src__')
+            if not path.exists(s):
+              s = None
+          else:
+            s = None
       if args.plugins:
         classpathentry('lib', j, s, exported=True)
       else:
diff --git a/tools/gwt-constants.defs b/tools/gwt-constants.defs
index 8bafddb..b76c04b 100644
--- a/tools/gwt-constants.defs
+++ b/tools/gwt-constants.defs
@@ -14,8 +14,14 @@
 ]
 
 GWT_TRANSITIVE_DEPS = [
+  '//lib/gwt:ant',
+  '//lib/gwt:colt',
   '//lib/gwt:javax-validation',
   '//lib/gwt:javax-validation_src',
+  '//lib/gwt:jsinterop-annotations',
+  '//lib/gwt:jsinterop-annotations_src',
+  '//lib/gwt:tapestry',
+  '//lib/gwt:w3c-css-sac',
   '//lib/ow2:ow2-asm',
   '//lib/ow2:ow2-asm-analysis',
   '//lib/ow2:ow2-asm-commons',
diff --git a/tools/java_doc.defs b/tools/java_doc.defs
index 41a8730..583407c6 100644
--- a/tools/java_doc.defs
+++ b/tools/java_doc.defs
@@ -2,23 +2,22 @@
     name,
     title,
     pkgs,
-    paths,
+    source_jar,
     srcs = [],
     deps = [],
     visibility = [],
-    do_it_wrong = False,
     external_docs = [],
   ):
-  if do_it_wrong:
-    sourcepath = paths
-  else:
-    sourcepath = ['$SRCDIR/' + n for n in paths]
-  external_docs.insert(0, 'http://docs.oracle.com/javase/7/docs/api')
+  # TODO(davido): Actually we shouldn't need to extract the source
+  # archive, javadoc should just work with provided archive.
+  external_docs.insert(0, 'http://docs.oracle.com/javase/8/docs/api')
   genrule(
     name = name,
     cmd = ' '.join([
-      'while ! test -f .buckconfig; do cd ..; done;',
+      'mkdir $TMP/sourcepath &&',
+      'unzip $(location %s) -d $TMP/sourcepath &&' % source_jar,
       'javadoc',
+      '-Xdoclint:-missing',
       '-quiet',
       '-protected',
       '-encoding UTF-8',
@@ -28,8 +27,7 @@
       ' '.join(['-link %s' % url for url in external_docs]),
       '-subpackages ',
       ':'.join(pkgs),
-      '-sourcepath ',
-      ':'.join(sourcepath),
+      '-sourcepath $TMP/sourcepath',
       ' -classpath ',
       ':'.join(['$(classpath %s)' % n for n in deps]),
       '-d $TMP',
@@ -37,4 +35,4 @@
     srcs = srcs,
     out = name + '.jar',
     visibility = visibility,
-)
+  )
diff --git a/tools/jgit-snapshot-deploy-pom.diff b/tools/jgit-snapshot-deploy-pom.diff
new file mode 100644
index 0000000..01f50e4
--- /dev/null
+++ b/tools/jgit-snapshot-deploy-pom.diff
@@ -0,0 +1,43 @@
+diff --git a/pom.xml b/pom.xml
+index d256bbb..7e523fd 100644
+--- a/pom.xml
++++ b/pom.xml
+@@ -226,6 +226,10 @@
+ 
+   <pluginRepositories>
+     <pluginRepository>
++      <id>gerrit-maven</id>
++      <url>https://gerrit-maven.commondatastorage.googleapis.com</url>
++    </pluginRepository>
++    <pluginRepository>
+       <id>repo.eclipse.org.cbi-releases</id>
+       <url>https://repo.eclipse.org/content/repositories/cbi-releases/</url>
+     </pluginRepository>
+@@ -236,6 +240,13 @@
+   </pluginRepositories>
+ 
+   <build>
++    <extensions>
++      <extension>
++        <groupId>com.googlesource.gerrit</groupId>
++        <artifactId>gs-maven-wagon</artifactId>
++        <version>3.3</version>
++      </extension>
++    </extensions>
+     <pluginManagement>
+       <plugins>
+         <plugin>
+@@ -649,9 +660,10 @@
+ 
+   <distributionManagement>
+     <repository>
+-      <id>repo.eclipse.org</id>
+-      <name>JGit Maven Repository - Releases</name>
+-      <url>https://repo.eclipse.org/content/repositories/jgit-releases/</url>
++      <id>gerrit-maven-repository</id>
++      <name>Gerrit Maven Repository</name>
++      <url>gs://gerrit-maven</url>
++      <uniqueVersion>true</uniqueVersion>
+     </repository>
+     <snapshotRepository>
+       <id>repo.eclipse.org</id>
diff --git a/tools/js/BUCK b/tools/js/BUCK
index ba4f19c..9eb0c91 100644
--- a/tools/js/BUCK
+++ b/tools/js/BUCK
@@ -1,14 +1,26 @@
 python_binary(
   name = 'bower2buck',
   main = 'bower2buck.py',
-  deps = ['//tools:util'],
+  deps = [
+    '//tools:util',
+    ":bowerutil",
+    ],
   visibility = ['PUBLIC'],
 )
 
+python_library(
+  name = 'bowerutil',
+  srcs = [ 'bowerutil.py' ],
+  visibility = [ 'PUBLIC' ],
+)
+
 python_binary(
   name = 'download_bower',
   main = 'download_bower.py',
-  deps = ['//tools:util'],
+  deps = [
+    '//tools:util',
+    ":bowerutil",
+  ],
   visibility = ['PUBLIC'],
 )
 
diff --git a/tools/js/BUILD b/tools/js/BUILD
new file mode 100644
index 0000000..fedaf7f
--- /dev/null
+++ b/tools/js/BUILD
@@ -0,0 +1 @@
+exports_files(["run_npm_binary.py"])
diff --git a/tools/js/bower2bazel.py b/tools/js/bower2bazel.py
new file mode 100755
index 0000000..08a997f
--- /dev/null
+++ b/tools/js/bower2bazel.py
@@ -0,0 +1,230 @@
+#!/usr/bin/env python
+# Copyright (C) 2015 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.
+
+"""Suggested call sequence:
+
+python tools/js/bower2bazel.py -w lib/js/bower_archives.bzl -b lib/js/bower_components.bzl
+"""
+
+from __future__ import print_function
+
+import collections
+import json
+import hashlib
+import optparse
+import os
+import subprocess
+import sys
+import tempfile
+import glob
+import bowerutil
+
+# Map license names to our canonical names.
+license_map = {
+  "http://polymer.github.io/LICENSE.txt": "polymer",
+  "Apache-2.0": "Apache2.0",
+
+  # TODO(hanwen): remove these, and add appropriate license files under //lib
+  "BSD": "polymer",
+  "MIT": "polymer",
+  "BSD-3-Clause": "polymer",
+}
+
+# list of licenses for packages that don't specify one in their bower.json file.
+package_licenses = {
+  "es6-promise": "es6-promise",
+  "fetch": "fetch",
+  "moment": "moment",
+  "page": "page.js",
+  "lodash": "polymer", # MIT, actually.
+  "promise-polyfill": "promise-polyfill",
+  "webcomponentsjs": "polymer",   # self-identifies as BSD.
+  "sinon-chai": "polymer", # WTFPL & BSD.
+  "sinonjs": "polymer", # BSD.
+}
+
+
+def build_bower_json(version_targets):
+  """Generate bower JSON file, return its path."""
+  bower_json = collections.OrderedDict()
+  bower_json['name'] = 'bower2buck-output'
+  bower_json['version'] = '0.0.0'
+  bower_json['description'] = 'Auto-generated bower.json for dependency management'
+  bower_json['private'] = True
+  bower_json['dependencies'] = {}
+
+  for v in version_targets:
+    fn = os.path.join("bazel-out/local-fastbuild/bin", v.lstrip("/").replace(":", "/"))
+    with open(fn) as f:
+      j = json.load(f)
+      if "" in j:
+        # drop dummy entries.
+        del j[""]
+      bower_json['dependencies'].update(j)
+
+  tmpdir = tempfile.mkdtemp()
+  ret = os.path.join(tmpdir, 'bower.json')
+  with open(ret, 'w') as f:
+    json.dump(bower_json, f, indent=2)
+  return ret
+
+
+def bower_command(args):
+  base = subprocess.check_output(["bazel", "info", "output_base"]).strip()
+  exp = os.path.join(base, "external", "bower", "*npm_binary.tgz")
+  fs = sorted(glob.glob(exp))
+  assert len(fs) == 1, "bower tarball not found or have multiple versions %s" % fs
+  return ["python", os.getcwd() + "/tools/js/run_npm_binary.py", sorted(fs)[0]] + args
+
+
+def main(args):
+  opts = optparse.OptionParser()
+  opts.add_option('-w', help='.bzl output for WORKSPACE')
+  opts.add_option('-b', help='.bzl output for //lib:BUILD')
+  opts, args = opts.parse_args()
+
+  target_str = subprocess.check_output([
+    "bazel", "query", "kind(bower_component_bundle, //polygerrit-ui/...)"])
+  seed_str = subprocess.check_output([
+    "bazel", "query", "attr(seed, 1, kind(bower_component, deps(//polygerrit-ui/...)))"])
+  targets = [s for s in target_str.split('\n') if s]
+  seeds = [s for s in seed_str.split('\n') if s]
+  prefix = "//lib/js:"
+  non_seeds = [s for s in seeds if not s.startswith(prefix)]
+  assert not non_seeds, non_seeds
+  seeds = set([s[len(prefix):] for s in seeds])
+
+  version_targets = [t + "-versions.json" for t in targets]
+
+  subprocess.check_call(['bazel', 'build'] + version_targets)
+  bower_json_path = build_bower_json(version_targets)
+  dir = os.path.dirname(bower_json_path)
+  cmd = bower_command(["install"])
+
+  build_out = sys.stdout
+  if opts.b:
+    build_out = open(opts.b + ".tmp", 'w')
+
+  ws_out = sys.stdout
+  if opts.b:
+    ws_out = open(opts.w + ".tmp", 'w')
+
+  header = """# DO NOT EDIT
+# generated with the following command:
+#
+#   %s
+#
+
+""" % ' '.join(sys.argv)
+
+  ws_out.write(header)
+  build_out.write(header)
+
+  oldwd = os.getcwd()
+  os.chdir(dir)
+  subprocess.check_call(cmd)
+
+  interpret_bower_json(seeds, ws_out, build_out)
+  ws_out.close()
+  build_out.close()
+
+  os.chdir(oldwd)
+  os.rename(opts.w + ".tmp", opts.w)
+  os.rename(opts.b + ".tmp", opts.b)
+
+
+def dump_workspace(data, seeds, out):
+  out.write('load("//tools/bzl:js.bzl", "bower_archive")\n')
+  out.write('def load_bower_archives():\n')
+
+  for d in data:
+    if d["name"] in seeds:
+      continue
+    out.write("""  bower_archive(
+    name = "%(name)s",
+    package = "%(normalized-name)s",
+    version = "%(version)s",
+    sha1 = "%(bazel-sha1)s")
+""" % d)
+
+
+def dump_build(data, seeds, out):
+  out.write('load("//tools/bzl:js.bzl", "bower_component")\n')
+  out.write('def define_bower_components():\n')
+  for d in data:
+    out.write("  bower_component(\n")
+    out.write("    name = \"%s\",\n" % d["name"])
+    out.write("    license = \"//lib:LICENSE-%s\",\n" % d["bazel-license"])
+    deps = sorted(d.get("dependencies", {}).keys())
+    if deps:
+      if len(deps) == 1:
+        out.write("    deps = [ \":%s\" ],\n" % deps[0])
+      else:
+        out.write("    deps = [\n")
+        for dep in deps:
+          out.write("      \":%s\",\n" % dep)
+        out.write("    ],\n")
+    if d["name"] in seeds:
+      out.write("    seed = True,\n")
+    out.write("  )\n")
+  # done
+
+
+def interpret_bower_json(seeds, ws_out, build_out):
+  out = subprocess.check_output(["find", "bower_components/", "-name", ".bower.json"])
+
+  data = []
+  for f in sorted(out.split('\n')):
+    if not f:
+      continue
+    pkg = json.load(open(f))
+    pkg_name = pkg["name"]
+
+    pkg["bazel-sha1"] = bowerutil.hash_bower_component(
+      hashlib.sha1(), os.path.dirname(f)).hexdigest()
+    license = pkg.get("license", None)
+    if type(license) == type([]):
+      # WTF? Some package specify a list of licenses. ("GPL", "MIT")
+      pick = license[0]
+      sys.stderr.write("package %s has multiple licenses: %s, picking %s" % (pkg_name, ", ".join(license), pick))
+      license = pick
+
+    if license:
+      license = license_map.get(license, license)
+    else:
+      if pkg_name not in package_licenses:
+        msg = "package %s does not specify license: %s" % (pkg_name, pkg)
+        sys.stderr.write(msg)
+        raise Exception(msg)
+      license = package_licenses[pkg_name]
+
+    pkg["bazel-license"] = license
+
+    # TODO(hanwen): bower packages can also have 'fully qualified'
+    # names, ("PolymerElements/iron-ajax") as well as short names
+    # ("iron-ajax").  It is possible for bower.json files to refer to
+    # long names as their dependencies. If any package does this, we
+    # will have to either 1) strip off the prefix (typically github
+    # user?), or 2) build a map of short name <=> fully qualified
+    # name. For now, we just ignore the problem.
+    pkg["normalized-name"] = pkg["name"]
+    data.append(pkg)
+
+  dump_workspace(data, seeds, ws_out)
+  dump_build(data, seeds, build_out)
+
+
+if __name__ == '__main__':
+  main(sys.argv[1:])
diff --git a/tools/js/bower2buck.py b/tools/js/bower2buck.py
index 81072da..d99b282 100755
--- a/tools/js/bower2buck.py
+++ b/tools/js/bower2buck.py
@@ -26,8 +26,7 @@
 import sys
 import tempfile
 
-from tools import util
-
+from tools.js import bowerutil
 
 # This script is run with `buck run`, but needs to shell out to buck; this is
 # only possible if we avoid buckd.
@@ -78,7 +77,7 @@
     self.version = bower_json['version']
     self.deps = bower_json.get('dependencies', {})
     self.license = bower_json.get('license', 'NO LICENSE')
-    self.sha1 = util.hash_bower_component(
+    self.sha1 = bowerutil.hash_bower_component(
         hashlib.sha1(), os.path.dirname(bower_json_path)).hexdigest()
 
   def to_rule(self, packages):
@@ -106,6 +105,7 @@
 
 
 def build_bower_json(targets, buck_out):
+  """create bower.json so 'bower install' fetches transitive deps"""
   bower_json = collections.OrderedDict()
   bower_json['name'] = 'bower2buck-output'
   bower_json['version'] = '0.0.0'
@@ -117,6 +117,9 @@
       ['buck', 'query', '-v', '0',
        "filter('__download_bower', deps(%s))" % '+'.join(targets)],
       env=BUCK_ENV)
+
+  # __bower_version contains the version number coming from version
+  # attr in BUCK/BUILD
   deps = deps.replace('__download_bower', '__bower_version').split()
   subprocess.check_call(['buck', 'build'] + deps, env=BUCK_ENV)
 
diff --git a/tools/js/bowerutil.py b/tools/js/bowerutil.py
new file mode 100644
index 0000000..8e8e835
--- /dev/null
+++ b/tools/js/bowerutil.py
@@ -0,0 +1,46 @@
+# Copyright (C) 2013 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+
+
+def hash_bower_component(hash_obj, path):
+  """Hash the contents of a bower component directory.
+
+  This is a stable hash of a directory downloaded with `bower install`, minus
+  the .bower.json file, which is autogenerated each time by bower. Used in lieu
+  of hashing a zipfile of the contents, since zipfiles are difficult to hash in
+  a stable manner.
+
+  Args:
+    hash_obj: an open hash object, e.g. hashlib.sha1().
+    path: path to the directory to hash.
+
+  Returns:
+    The passed-in hash_obj.
+  """
+  if not os.path.isdir(path):
+    raise ValueError('Not a directory: %s' % path)
+
+  path = os.path.abspath(path)
+  for root, dirs, files in os.walk(path):
+    dirs.sort()
+    for f in sorted(files):
+      if f == '.bower.json':
+        continue
+      p = os.path.join(root, f)
+      hash_obj.update(p[len(path)+1:])
+      hash_obj.update(open(p).read())
+
+  return hash_obj
diff --git a/tools/js/download_bower.py b/tools/js/download_bower.py
old mode 100644
new mode 100755
index bcc417c..f5b7bf5
--- a/tools/js/download_bower.py
+++ b/tools/js/download_bower.py
@@ -23,8 +23,7 @@
 import subprocess
 import sys
 
-from tools import util
-
+import bowerutil
 
 CACHE_DIR = os.path.expanduser(os.path.join(
     '~', '.gerritcodereview', 'buck-cache', 'downloaded-artifacts'))
@@ -39,16 +38,20 @@
 def bower_info(bower, name, package, version):
   cmd = bower_cmd(bower, '-l=error', '-j',
                   'info', '%s#%s' % (package, version))
-  p = subprocess.Popen(cmd , stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+  try:
+    p = subprocess.Popen(cmd , stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+  except:
+    sys.stderr.write("error executing: %s\n" % ' '.join(cmd))
+    raise
   out, err = p.communicate()
   if p.returncode:
     sys.stderr.write(err)
-    raise OSError('Command failed: %s' % cmd)
+    raise OSError('Command failed: %s' % ' '.join(cmd))
 
   try:
     info = json.loads(out)
   except ValueError:
-    raise ValueError('invalid JSON from %s:\n%s' % (cmd, out))
+    raise ValueError('invalid JSON from %s:\n%s' % (" ".join(cmd), out))
   info_name = info.get('name')
   if info_name != name:
     raise ValueError('expected package name %s, got: %s' % (name, info_name))
@@ -82,7 +85,11 @@
   opts.add_option('-v', help='version number')
   opts.add_option('-s', help='expected content sha1')
   opts.add_option('-o', help='output file location')
-  opts, _ = opts.parse_args()
+  opts, args_ = opts.parse_args(args)
+
+  assert opts.p
+  assert opts.v
+  assert opts.n
 
   cwd = os.getcwd()
   outzip = os.path.join(cwd, opts.o)
@@ -100,7 +107,7 @@
 
     if opts.s:
       path = os.path.join(bc, opts.n)
-      sha1 = util.hash_bower_component(hashlib.sha1(), path).hexdigest()
+      sha1 = bowerutil.hash_bower_component(hashlib.sha1(), path).hexdigest()
       if opts.s != sha1:
         print((
           '%s#%s:\n'
diff --git a/tools/js/run_npm_binary.py b/tools/js/run_npm_binary.py
index d76eff5..d769b98 100644
--- a/tools/js/run_npm_binary.py
+++ b/tools/js/run_npm_binary.py
@@ -25,8 +25,6 @@
 import tarfile
 import tempfile
 
-from tools import util
-
 
 def extract(path, outdir, bin):
   if os.path.exists(os.path.join(outdir, bin)):
@@ -59,19 +57,21 @@
     # finished.
     extract_one(tar.getmember(bin))
 
-
 def main(args):
   path = args[0]
   suffix = '.npm_binary.tgz'
   tgz = os.path.basename(path)
+
   parts = tgz[:-len(suffix)].split('@')
 
   if not tgz.endswith(suffix) or len(parts) != 2:
     print('usage: %s <path/to/npm_binary>' % sys.argv[0], file=sys.stderr)
     return 1
 
-  name, version = parts
-  sha1 = util.hash_file(hashlib.sha1(), path).hexdigest()
+  name, _ = parts
+
+  # Avoid importing from gerrit because we don't want to depend on the right CWD.
+  sha1 = hashlib.sha1(open(path, 'rb').read()).hexdigest()
   outdir = '%s-%s' % (path[:-len(suffix)], sha1)
   rel_bin = os.path.join('package', 'bin', name)
   bin = os.path.join(outdir, rel_bin)
diff --git a/tools/maven/BUCK b/tools/maven/BUCK
index 322b5a2..0541fc0 100644
--- a/tools/maven/BUCK
+++ b/tools/maven/BUCK
@@ -1,6 +1,6 @@
-include_defs('//VERSION')
 include_defs('//tools/maven/package.defs')
 include_defs('//tools/maven/repository.defs')
+include_defs('//version.bzl')
 
 if GERRIT_VERSION.endswith('-SNAPSHOT'):
   URL = MAVEN_SNAPSHOT_URL
diff --git a/tools/maven/BUILD b/tools/maven/BUILD
new file mode 100644
index 0000000..14eb2be
--- /dev/null
+++ b/tools/maven/BUILD
@@ -0,0 +1,31 @@
+load('//:version.bzl', 'GERRIT_VERSION')
+load('//tools/maven:package.bzl', 'maven_package')
+
+MAVEN_REPOSITORY = 'sonatype-nexus-staging'
+# TODO(davido): support snapshot repositories
+MAVEN_RELEASE_URL = 'https://oss.sonatype.org/service/local/staging/deploy/maven2'
+
+maven_package(
+  repository = MAVEN_REPOSITORY,
+  url = MAVEN_RELEASE_URL,
+  version = GERRIT_VERSION,
+  jar = {
+    'gerrit-acceptance-framework': '//gerrit-acceptance-framework:acceptance-framework_deploy.jar',
+    'gerrit-extension-api': '//gerrit-extension-api:extension-api_deploy.jar',
+    'gerrit-plugin-api': '//gerrit-plugin-api:plugin-api_deploy.jar',
+    'gerrit-plugin-gwtui': '//gerrit-plugin-gwtui:gwtui-api_deploy.jar',
+  },
+  src = {
+    'gerrit-acceptance-framework': '//gerrit-acceptance-framework:liblib-src.jar',
+    'gerrit-extension-api': '//gerrit-extension-api:libapi-src.jar',
+    'gerrit-plugin-api': '//gerrit-plugin-api:plugin-api-sources_deploy.jar',
+    'gerrit-plugin-gwtui': '//gerrit-plugin-gwtui:gwtui-api-source_deploy.jar',
+  },
+  doc = {
+    'gerrit-acceptance-framework': '//gerrit-acceptance-framework:acceptance-framework-javadoc',
+    'gerrit-extension-api': '//gerrit-extension-api:extension-api-javadoc',
+    'gerrit-plugin-api': '//gerrit-plugin-api:plugin-api-javadoc',
+    'gerrit-plugin-gwtui': '//gerrit-plugin-gwtui:gwtui-api-javadoc',
+  },
+  war = {'gerrit-war': '//:release'},
+)
diff --git a/tools/maven/api.sh b/tools/maven/api.sh
index c7ce65e..93b5f2e 100755
--- a/tools/maven/api.sh
+++ b/tools/maven/api.sh
@@ -14,16 +14,20 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-if [[ "$#" == "0" ]] ; then
+if [[ "$#" != "2" ]] ; then
   cat <<EOF
-Usage: run "$0 COMMAND" from the top of your workspace, where
-COMMAND is one of
+Usage: run "$0 COMMAND BUILD-TOOL" from the top of your workspace,
+where COMMAND is one of
 
   install
   deploy
   war_install
   war_deploy
 
+and BUILD-TOOL is one of
+
+  buck
+  bazel
 Set VERBOSE in the environment to get more information.
 
 EOF
@@ -54,16 +58,33 @@
     ;;
 esac
 
+case "$2" in
+bazel)
+    buildProc=bazel
+    ;;
+buck)
+    buildProc=buck
+    ;;
+*)
+    echo "unknown build-tool $2. Should be buck or bazel."
+    exit 1
+    ;;
+esac
+
 if [[ "${VERBOSE:-x}" != "x" ]]; then
   set -o xtrace
 fi
 
-buck build //tools/maven:gen_${command} || \
-  { echo "buck failed to build gen_${command}. Use VERBOSE=1 for more info" ; exit 1 ; }
+$buildProc build //tools/maven:gen_${command} || \
+  { echo "$buildProc failed to build gen_${command}. Use VERBOSE=1 for more info" ; exit 1 ; }
 
-script="./buck-out/gen/tools/maven/gen_${command}/${command}.sh"
-
-# The PEX wrapper does some funky exit handling, so even if the script
-# does "exit(0)", the return status is '1'. So we can't tell if the
-# following invocation was successful.
-${script}
+if [[ "$buildProc" = "bazel" ]]; then
+  script="./bazel-genfiles/tools/maven/${command}.sh"
+  ${script}
+else
+  script="./buck-out/gen/tools/maven/gen_${command}/${command}.sh"
+  # The PEX wrapper does some funky exit handling, so even if the script
+  # does "exit(0)", the return status is '1'. So we can't tell if the
+  # following invocation was successful.
+  ${script}
+fi
diff --git a/tools/maven/package.bzl b/tools/maven/package.bzl
new file mode 100644
index 0000000..fbd08c6
--- /dev/null
+++ b/tools/maven/package.bzl
@@ -0,0 +1,93 @@
+# Copyright (C) 2016 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.
+
+sh_bang_template = (' && '.join([
+  "echo '#!/bin/bash -e' > $@",
+  'echo "# this script should run from the root of your workspace." >> $@',
+  'echo "" >> $@',
+  "echo 'if [[ \"$$VERBOSE\" ]]; then set -x ; fi' >> $@",
+  'echo "" >> $@',
+  'echo %s >> $@',
+  'echo "" >> $@',
+  'echo %s >> $@']))
+
+def maven_package(
+    version,
+    repository = None,
+    url = None,
+    jar = {},
+    src = {},
+    doc = {},
+    war = {}):
+
+  build_cmd = ['bazel', 'build']
+  mvn_cmd = ['python', 'tools/maven/mvn.py', '-v', version]
+  api_cmd = mvn_cmd[:]
+  api_targets = []
+  for type,d in [('jar', jar), ('java-source', src), ('javadoc', doc)]:
+    for a,t in sorted(d.items()):
+      api_cmd.append('-s %s:%s:$(location %s)' % (a,type,t))
+      api_targets.append(t)
+
+  native.genrule(
+    name = 'gen_api_install',
+    cmd = sh_bang_template % (
+      ' '.join(build_cmd + api_targets),
+      ' '.join(api_cmd + ['-a', 'install'])),
+    srcs = api_targets,
+    outs = ['api_install.sh'],
+    executable = True,
+  )
+
+  if repository and url:
+    native.genrule(
+      name = 'gen_api_deploy',
+      cmd = sh_bang_template % (
+        ' '.join(build_cmd + api_targets),
+        ' '.join(api_cmd + ['-a', 'deploy',
+                            '--repository', repository,
+                            '--url', url])),
+      srcs = api_targets,
+      outs = ['api_deploy.sh'],
+      executable = True,
+    )
+
+  war_cmd = mvn_cmd[:]
+  war_targets = []
+  for a,t in sorted(war.items()):
+    war_cmd.append('-s %s:war:$(location %s)' % (a,t))
+    war_targets.append(t)
+
+  native.genrule(
+    name = 'gen_war_install',
+    cmd = sh_bang_template % (' '.join(build_cmd + war_targets),
+                              ' '.join(war_cmd + ['-a', 'install'])),
+    srcs = war_targets,
+    outs = ['war_install.sh'],
+    executable = True,
+  )
+
+  if repository and url:
+    native.genrule(
+      name = 'gen_war_deploy',
+      cmd = sh_bang_template % (
+          ' '.join(build_cmd + war_targets),
+          ' '.join(war_cmd + [
+        '-a', 'deploy',
+        '--repository', repository,
+        '--url', url])),
+      srcs = war_targets,
+      outs = ['war_deploy.sh'],
+      executable = True,
+    )
diff --git a/tools/maven/package.defs b/tools/maven/package.defs
index c412ebd..a557170 100644
--- a/tools/maven/package.defs
+++ b/tools/maven/package.defs
@@ -13,10 +13,10 @@
 # limitations under the License.
 
 sh_bang_template = (' && '.join([
-  "echo '#!/bin/bash -eu' > $OUT",
+  "echo '#!/bin/bash -e' > $OUT",
   'echo "# this script should run from the root of your workspace." >> $OUT',
   'echo "" >> $OUT',
-  "echo 'if [[ -n \"$${VERBOSE:-}\" ]]; then set -x ; fi' >> $OUT",
+  "echo 'if [[ \"${VERBOSE}\" ]]; then set -x ; fi' >> $OUT",
   'echo "" >> $OUT',
   'echo %s >> $OUT',
   'echo "" >> $OUT',
diff --git a/tools/util.py b/tools/util.py
index 08a803f..e8182ed 100644
--- a/tools/util.py
+++ b/tools/util.py
@@ -12,7 +12,6 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import os
 from os import path
 
 REPO_ROOTS = {
@@ -70,34 +69,3 @@
         break
       hash_obj.update(b)
   return hash_obj
-
-
-def hash_bower_component(hash_obj, path):
-  """Hash the contents of a bower component directory.
-
-  This is a stable hash of a directory downloaded with `bower install`, minus
-  the .bower.json file, which is autogenerated each time by bower. Used in lieu
-  of hashing a zipfile of the contents, since zipfiles are difficult to hash in
-  a stable manner.
-
-  Args:
-    hash_obj: an open hash object, e.g. hashlib.sha1().
-    path: path to the directory to hash.
-
-  Returns:
-    The passed-in hash_obj.
-  """
-  if not os.path.isdir(path):
-    raise ValueError('Not a directory: %s' % path)
-
-  path = os.path.abspath(path)
-  for root, dirs, files in os.walk(path):
-    dirs.sort()
-    for f in sorted(files):
-      if f == '.bower.json':
-        continue
-      p = os.path.join(root, f)
-      hash_obj.update(p[len(path)+1:])
-      hash_file(hash_obj, p)
-
-  return hash_obj
diff --git a/tools/version.py b/tools/version.py
index 9f03a59..eac2700 100755
--- a/tools/version.py
+++ b/tools/version.py
@@ -53,7 +53,7 @@
   replace_in_file(pom, src_pattern)
 
 src_pattern = re.compile(r"^(GERRIT_VERSION = ')([-.\w]+)(')$", re.MULTILINE)
-replace_in_file('VERSION', src_pattern)
+replace_in_file('version.bzl', src_pattern)
 
 src_pattern = re.compile(r'^(\s*-DarchetypeVersion=)([-.\w]+)(\s*\\)$',
                          re.MULTILINE)
diff --git a/tools/workspace-status.sh b/tools/workspace-status.sh
new file mode 100755
index 0000000..6f17754
--- /dev/null
+++ b/tools/workspace-status.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+
+# This script will be run by bazel when the build process starts to
+# generate key-value information that represents the status of the
+# workspace. The output should be like
+#
+# KEY1 VALUE1
+# KEY2 VALUE2
+#
+# If the script exits with non-zero code, it's considered as a failure
+# and the output will be discarded.
+
+function rev() {
+  cd $1; git describe --always --match "v[0-9].*" --dirty
+}
+
+echo STABLE_BUILD_GERRIT_LABEL $(rev .)
+for p in plugins/* ; do
+  test -d "$p" || continue
+  echo STABLE_BUILD_$(echo $(basename $p)_LABEL|tr [a-z] [A-Z]) $(rev $p)
+done
diff --git a/VERSION b/version.bzl
similarity index 81%
rename from VERSION
rename to version.bzl
index 9cec0ea..3035c93 100644
--- a/VERSION
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = '2.13.2'
+GERRIT_VERSION = '2.14-SNAPSHOT'