Merge branch 'stable-2.12'

* stable-2.12:
  Document that ldap.groupBase and ldap.accountBase are repeatable
  Put Change-Id after Test: footers in commit messages.
  Remove bucklets/local_jar.bucklet soft-link to removed lib/local.defs
  Normalize case of {Author,Committer}Predicate
  OAuth-Linking: Don't create new account when claimed identity unknown
  Update 2.11.5 release notes to mention forked buck
  Revert "Update buck to ba9f239f69287a553ca93af76a27484d83693563"

Change-Id: I46c53b5c43ecbdc4d63cb03da25c35737b2c5afd
diff --git a/.buckconfig b/.buckconfig
index 51318f3..c766d30 100644
--- a/.buckconfig
+++ b/.buckconfig
@@ -8,7 +8,9 @@
   docs = //Documentation:searchfree
   firefox = //:firefox
   gerrit = //:gerrit
+  gwtgerrit = //:gwtgerrit
   headless = //:headless
+  polygerrit = //:polygerrit
   release = //:release
   safari = //:safari
   soyc = //gerrit-gwtui:ui_soyc
@@ -22,8 +24,11 @@
   src_roots = java, resources
 
 [project]
-  ignore = .git
+  ignore = .git, eclipse-out
 
 [cache]
   mode = dir
   dir = ~/.gerritcodereview/buck-cache/locally-built-artifacts
+
+[test]
+  excluded_labels = manual
diff --git a/.buckversion b/.buckversion
index 9daac2c..b2b427a 100644
--- a/.buckversion
+++ b/.buckversion
@@ -1 +1 @@
-1b03b4313b91b634bd604fc3487a05f877e59dee
+75b74ccf90a590b284b1a1553dc48af8844a9ca7
diff --git a/.gitignore b/.gitignore
index 32a1826..ed6b163 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@
 /.buckd
 /buck-cache
 /buck-out
+/eclipse-out
 /extras
 /local.properties
 *.pyc
diff --git a/BUCK b/BUCK
index c986874..9657ff3 100644
--- a/BUCK
+++ b/BUCK
@@ -1,10 +1,12 @@
 include_defs('//tools/build.defs')
 
 gerrit_war(name = 'gerrit')
-gerrit_war(name = 'headless', ui = None)
-gerrit_war(name = 'chrome',   ui = 'ui_chrome')
-gerrit_war(name = 'firefox',  ui = 'ui_firefox')
-gerrit_war(name = 'safari',   ui = 'ui_safari')
+gerrit_war(name = 'gwtgerrit',   ui = 'ui_dbg')
+gerrit_war(name = 'headless',    ui = None)
+gerrit_war(name = 'chrome',      ui = 'ui_chrome')
+gerrit_war(name = 'firefox',     ui = 'ui_firefox')
+gerrit_war(name = 'safari',      ui = 'ui_safari')
+gerrit_war(name = 'polygerrit',  ui = 'polygerrit')
 gerrit_war(name = 'withdocs', docs = True)
 gerrit_war(name = 'release',  ui = 'ui_optdbg_r', docs = True, context = ['//plugins:core'],  visibility = ['//tools/maven:'])
 
diff --git a/Documentation/BUCK b/Documentation/BUCK
index 126bf1f..62c0e07 100644
--- a/Documentation/BUCK
+++ b/Documentation/BUCK
@@ -1,12 +1,16 @@
 include_defs('//Documentation/asciidoc.defs')
 include_defs('//Documentation/config.defs')
+include_defs('//Documentation/license.defs')
 include_defs('//tools/git.defs')
 
 DOC_DIR = 'Documentation'
-JSUI = '//gerrit-gwtui:ui_module'
-MAIN = '//gerrit-pgm:pgm'
+
+JSUI_JAVA_DEPS = ['//gerrit-gwtui:ui_module']
+JSUI_NON_JAVA_DEPS = ['//polygerrit-ui/app:polygerrit_ui']
+MAIN_JAVA_DEPS = ['//gerrit-pgm:pgm']
 SRCS = glob(['*.txt'], excludes = ['licenses.txt'])
 
+
 genasciidoc(
   name = 'html',
   out = 'html.zip',
@@ -28,21 +32,20 @@
   visibility = ['PUBLIC'],
 )
 
-genrule(
+genlicenses(
   name = 'licenses.txt',
-  cmd = '$(exe :gen_licenses) --asciidoc '
-    + '--classpath $(classpath %s) ' % MAIN
-    + '--classpath $(classpath %s) ' % JSUI
-    + MAIN + ' ' + JSUI + ' >$OUT',
+  opts = ['--asciidoc'],
+  java_deps = JSUI_JAVA_DEPS + MAIN_JAVA_DEPS,
+  non_java_deps = JSUI_NON_JAVA_DEPS,
   out = 'licenses.txt',
 )
 
 # Required by Google for gerrit-review.
-genrule(
+genlicenses(
   name = 'js_licenses.txt',
-  cmd = '$(exe :gen_licenses) --partial '
-    + '--classpath $(classpath %s) ' % JSUI
-    + JSUI + ' >$OUT',
+  opts = ['--partial'],
+  java_deps = JSUI_JAVA_DEPS,
+  non_java_deps = JSUI_NON_JAVA_DEPS,
   out = 'js_licenses.txt',
 )
 
diff --git a/Documentation/cmd-set-reviewers.txt b/Documentation/cmd-set-reviewers.txt
index 79f7651..d5b4908 100644
--- a/Documentation/cmd-set-reviewers.txt
+++ b/Documentation/cmd-set-reviewers.txt
@@ -10,17 +10,16 @@
   [--add <REVIEWER> ... | -a <REVIEWER> ...]
   [--remove <REVIEWER> ... | -r <REVIEWER> ...]
   [--]
-  {COMMIT | CHANGE-ID}...
+  {CHANGE-ID}...
 --
 
 == DESCRIPTION
 Adds or removes reviewers to the specified change, sending email
 notifications when changes are made.
 
-Changes should be specified as complete or abbreviated Change-Ids
-such as 'Iac6b2ac2'.  They may also be specified by numeric change
-identifiers, such as '8242' or by complete or abbreviated commit
-SHA-1s.
+Changes can be specified in the
+link:rest-api-changes.html#change-id[same format] supported by the REST
+API.
 
 == OPTIONS
 
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 4306fdb..df9ec26 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -145,7 +145,7 @@
 The configured <<ldap.username,ldap.username>> identity is not used to obtain
 account information.
 +
-* OAUTH
+* `OAUTH`
 +
 OAuth is a protocol that lets external apps request authorization to private
 details in a user's account without getting their password. This is
@@ -437,9 +437,9 @@
 [[auth.gitBasicAuth]]auth.gitBasicAuth::
 +
 If true then Git over HTTP and HTTP/S traffic is authenticated using
-standard BasicAuth and the credentials are validated against the randomly
-generated HTTP password or against LDAP when it is configured as Gerrit
-Web UI authentication method.
+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`).
 +
 This parameter affects git over HTTP traffic and access to the REST
 API. If set to false then Gerrit will authenticate through DIGEST
@@ -449,8 +449,30 @@
 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 `OAUTH`, Git clients may send OAuth 2 access tokens
+instead of passwords in the Basic authentication header. Note that provider
+specific plugins must be installed to facilitate this authentication scheme.
+If multiple OAuth 2 provider plugins are installed one of them must be
+selected as default with the `auth.gitOAuthProvider` option.
++
 By default this is set to false.
 
+[[auth.gitOAuthProvider]]auth.gitOAuthProvider::
++
+Selects the OAuth 2 provider to authenticate git over HTTP traffic with.
++
+In general there is no way to determine from an access token alone, which
+OAuth 2 provider to address to verify that token, and the BasicAuth
+scheme does not support amending such details. If multiple OAuth provider
+plugins in a system offer support for git over HTTP authentication site
+administrators must configure, which one to use as default provider.
+In case the provider cannot be determined from a request the access token
+will be sent to the default provider for verification.
++
+The value of this parameter must be the identifier of an OAuth 2 provider
+in the form `plugin-name:provider-name`. Consult the respective plugin
+documentation for details.
+
 [[auth.userNameToLowerCase]]auth.userNameToLowerCase::
 +
 If set the username that is received to authenticate a git operation
@@ -1414,7 +1436,8 @@
 httpd and sshd threads as some request processing code paths may
 need multiple connections.
 +
-Default is 8.
+Default is <<sshd.threads, sshd.threads>>
+ + <<httpd.maxThreads, httpd.maxThreads>> + 2.
 +
 This setting only applies if
 <<database.connectionPool,database.connectionPool>> is true.
@@ -1432,7 +1455,7 @@
 Maximum number of connections to keep idle in the pool.  If there
 are more idle connections, connections will be closed instead of
 being returned back to the pool.
-Default is 4.
+Default is min(<<database.poolLimit, database.poolLimit>>, 16).
 +
 This setting only applies if
 <<database.connectionPool,database.connectionPool>> is true.
@@ -3150,6 +3173,28 @@
 +
 Default is 10x reductionLimit (1,000,000).
 
+[[rules.maxSourceBytes]]rules.maxSourceBytes::
++
+Maximum input size (in bytes) of a Prolog rules.pl file.  Larger
+source files may need a larger rules.compileReductionLimit.  Consider
+using link:pgm-rulec.html[rulec] to precompile larger rule files.
++
+A size of 0 bytes disables rules, same as rules.enable = false.
++
+Common unit suffixes of 'k', 'm', or 'g' are supported.
++
+Default is 128 KiB.
+
+[[rules.maxPrologDatabaseSize]]rules.maxPrologDatabaseSize::
++
+Number of predicate clauses allowed to be defined in the Prolog
+database by project rules.  Very complex rules may need more than the
+default 256 limit, but cost more memory and may need more time to
+evaluate.  Consider using link:pgm-rulec.html[rulec] to precompile
+larger rule files.
++
+Default is 256.
+
 [[execution]]
 === Section execution
 
@@ -3415,7 +3460,7 @@
 If additional requests are received while all threads are busy they
 are queued and serviced in a first-come-first-served order.
 +
-By default, 1.5x the number of CPUs available to the JVM.
+By default, 2x the number of CPUs available to the JVM.
 
 [[sshd.batchThreads]]sshd.batchThreads::
 +
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index a40c5f3..9cf4e50 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -217,6 +217,19 @@
 The label is purely informational and values are not considered when
 determining whether a change is submittable.
 
+* `PatchSetLock`
++
+The `PatchSetLock` function provides a locking mechanism for patch
+sets.  This function's values are not considered when determining
+whether a change is submittable. When set, no new patchsets can be
+created and rebase and abandon are blocked.
++
+This function is designed to allow overlapping locks, so several lock
+accounts could lock the same change.
++
+Allowed range of values are 0 (Patch Set Unlocked) to 1 (Patch Set
+Locked).
+
 
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index 6e248c8..37e3eba 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -386,6 +386,27 @@
 link:https://gerrit.googlesource.com/plugins/menuextender/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[metrics-reporter-elasticsearch]]
+
+This plugin reports Gerrit metrics to Elasticsearch.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-elasticsearch[
+Project].
+
+[[metrics-reporter-graphite]]
+
+This plugin reports Gerrit metrics to Graphite.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-graphite[
+Project].
+
+[[metrics-reporter-jmx]]
+
+This plugin reports Gerrit metrics to JMX.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-jmx[
+Project].
+
 [[motd]]
 === motd
 
diff --git a/Documentation/dev-buck.txt b/Documentation/dev-buck.txt
index ec8515f..0cd5e84 100644
--- a/Documentation/dev-buck.txt
+++ b/Documentation/dev-buck.txt
@@ -6,8 +6,8 @@
 Note that you need to use Java 7 for building gerrit.
 
 There is currently no binary distribution of Buck, so it has to be manually
-built and installed.  Apache Ant is required.  Currently only Linux and Mac
-OS are supported.
+built and installed.  Apache Ant and gcc are required.  Currently only Linux
+and Mac OS are supported.
 
 Clone the git and build it:
 
@@ -97,7 +97,7 @@
 
 === Gerrit Development WAR File
 
-To build the Gerrit web application:
+To build the Gerrit web application that includes GWT UI and PolyGerrit UI:
 
 ----
   buck build gerrit
@@ -109,6 +109,18 @@
   buck-out/gen/gerrit/gerrit.war
 ----
 
+To build the Gerrit web application that includes only GWT UI:
+
+----
+  buck build gwtgerrit
+----
+
+The output executable WAR will be placed in:
+
+----
+  buck-out/gen/gwtgerrit/gwtgerrit.war
+----
+
 
 === Headless Mode
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index fdc7fab..13b6978 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.12 \
+    -DarchetypeVersion=2.13-SNAPSHOT \
     -DgroupId=com.googlesource.gerrit.plugins.testplugin \
     -DartifactId=testplugin
 ----
@@ -587,6 +587,48 @@
 $ ssh -p 29418 review.example.com sh ps
 ----
 
+[[search_operators]]
+=== Search Operators ===
+
+Plugins can define new search operators to extend change searching by
+implementing the `ChangeQueryBuilder.ChangeOperatorFactory` interface
+and registering it to an operator name in the plugin module's
+`configure()` method.  The search operator name is defined during
+registration via the DynamicMap annotation mechanism.  The plugin
+name will get appended to the annotated name, with an underscore
+in between, leading to the final operator name.  An example
+registration looks like this:
+
+    bind(ChangeOperatorFactory.class)
+       .annotatedWith(Exports.named("sample"))
+       .to(SampleOperator.class);
+
+If this is registered in the `myplugin` plugin, then the resulting
+operator will be named `sample_myplugin`.
+
+The search operator itself is implemented by ensuring that the
+`create()` method of the class implementing the
+`ChangeQueryBuilder.ChangeOperatorFactory` interface returns a
+`Predicate<ChangeData>`.  Here is a sample operator factory
+defintion which creates a `MyPredicate`:
+
+[source,java]
+----
+@Singleton
+public class SampleOperator
+    implements ChangeQueryBuilder.ChangeOperatorFactory {
+  public static class MyPredicate extends OperatorPredicate<ChangeData> {
+    ...
+  }
+
+  @Override
+  public Predicate<ChangeData> create(ChangeQueryBuilder builder, String value)
+      throws QueryParseException {
+    return new MyPredicate(value);
+  }
+}
+----
+
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
 
diff --git a/Documentation/gen_licenses.py b/Documentation/gen_licenses.py
index db3480b..47a132c 100755
--- a/Documentation/gen_licenses.py
+++ b/Documentation/gen_licenses.py
@@ -19,8 +19,8 @@
 
 import argparse
 from collections import defaultdict, deque
+import json
 from os import chdir, path
-import re
 from shutil import copyfileobj
 from subprocess import Popen, PIPE
 from sys import stdout, stderr
@@ -28,7 +28,6 @@
 parser = argparse.ArgumentParser()
 parser.add_argument('--asciidoc', action='store_true')
 parser.add_argument('--partial', action='store_true')
-parser.add_argument('--classpath', action='append')
 parser.add_argument('targets', nargs='+')
 args = parser.parse_args()
 
@@ -38,37 +37,35 @@
   '//lib/bouncycastle:bcprov',
 ]
 
+for target in args.targets:
+  if not target.startswith('//'):
+    print('Target must be absolute: %s' % target, file=stderr)
+
 def parse_graph():
   graph = defaultdict(list)
   while not path.isfile('.buckconfig'):
     chdir('..')
-  # TODO(davido): use passed in classpath from Buck instead
-  p = Popen(
-    ['buck', 'audit', 'classpath', '--dot'] + args.targets,
-    stdout = PIPE)
-  for line in p.stdout:
-    m = re.search(r'"(//.*?)" -> "(//.*?)";', line)
-    if not m:
-      continue
-    target, dep = m.group(1), m.group(2)
-    if args.partial:
-      if dep == '//lib/codemirror:js_minifier':
-        if target == '//lib/codemirror:js':
-          continue
-        if target.startswith('//lib/codemirror:mode_'):
-          continue
-      if target == '//gerrit-gwtui:ui_module' and \
-         dep == '//gerrit-gwtexpui:CSS':
+  query = ' + '.join('deps(%s)' % t for t in args.targets)
+  p = Popen([
+      'buck', 'query', query,
+      '--output-attributes=buck.direct_dependencies'], stdout=PIPE)
+  obj = json.load(p.stdout)
+  for target, attrs in obj.iteritems():
+    for dep in attrs['buck.direct_dependencies']:
+
+      if target in KNOWN_PROVIDED_DEPS:
         continue
 
-    # Dependencies included in provided_deps set are contained in audit
-    # classpath and must be sorted out. That's safe thing to do because
-    # they are not included in the final artifact.
-    if "DO_NOT_DISTRIBUTE" in dep:
-      if not target in KNOWN_PROVIDED_DEPS:
-        print('DO_NOT_DISTRIBUTE license for target: %s' % target, file=stderr)
-        exit(1)
-    else:
+      if args.partial:
+        if dep == '//lib/codemirror:js_minifier':
+          if target == '//lib/codemirror:js':
+            continue
+          if target.startswith('//lib/codemirror:mode_'):
+            continue
+        if (target == '//gerrit-gwtui:ui_module'
+            and dep == '//gerrit-gwtexpui:CSS'):
+          continue
+
       graph[target].append(dep)
   r = p.wait()
   if r != 0:
@@ -78,14 +75,27 @@
 graph = parse_graph()
 licenses = defaultdict(set)
 
+do_not_distribute = False
 queue = deque(args.targets)
 while queue:
   target = queue.popleft()
   for dep in graph[target]:
     if not dep.startswith('//lib:LICENSE-'):
       continue
+    if 'DO_NOT_DISTRIBUTE' in dep:
+      do_not_distribute = True
     licenses[dep].add(target)
   queue.extend(graph[target])
+
+if do_not_distribute:
+  print('DO_NOT_DISTRIBUTE license found', file=stderr)
+  for target in args.targets:
+    print('...via %s:' % target)
+    Popen(['buck', 'query',
+           'allpaths(%s, //lib:LICENSE-DO_NOT_DISTRIBUTE)' % target],
+          stdout=stderr).communicate()
+  exit(1)
+
 used = sorted(licenses.keys())
 
 if args.asciidoc:
@@ -156,6 +166,8 @@
       p = d[len('//lib:'):]
     else:
       p = d[d.index(':')+1:].lower()
+    if '__' in p:
+      p = p[:p.index('__')]
     print('* ' + p)
   if args.asciidoc:
     print()
diff --git a/Documentation/license.defs b/Documentation/license.defs
new file mode 100644
index 0000000..42dd3eb
--- /dev/null
+++ b/Documentation/license.defs
@@ -0,0 +1,29 @@
+def genlicenses(
+    name,
+    out,
+    opts = [],
+    java_deps = [],
+    non_java_deps = [],
+    visibility = []):
+  cmd = ['$(exe :gen_licenses)']
+  cmd.extend(opts)
+  cmd.append('>$OUT')
+  cmd.extend(java_deps)
+  cmd.extend(non_java_deps)
+
+  # Must use $(classpath) for Java deps, since transitive dependencies are not
+  # first-order dependencies of the output jar, so changes would not cause
+  # invalidation of the build cache key for the genrule.
+  cmd.extend('; true $(classpath %s)' % d for d in java_deps)
+
+  # Must use $(location) for non-Java deps, since $(classpath) will fail with an
+  # error. This is ok, because transitive dependencies are included in the
+  # output artifacts for everything _except_ Java libraries.
+  cmd.extend('; true $(location %s)' % d for d in non_java_deps)
+
+  genrule(
+    name = name,
+    out = out,
+    cmd = ' '.join(cmd),
+    visibility = visibility,
+  )
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index ad19046..ee05aa3 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1431,7 +1431,11 @@
       "status": "NEW",
       "created": "2013-02-01 09:59:32.126000000",
       "updated": "2013-02-21 11:16:36.775000000",
+      "starred": true,
       "mergeable": true,
+      "submittable": false,
+      "insertions": 145,
+      "deletions": 12,
       "_number": 3965,
       "owner": {
         "name": "John Doe"
@@ -1943,6 +1947,12 @@
 |`url_aliases`                  |optional|
 A map of URL path pairs, where the first URL path is an alias for the
 second URL path.
+|`email_notifications`          ||
+The type of email strategy to use. On `ENABLED`, the user will receive emails
+from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
+their own comments. On `DISABLED` the user will not receive any email
+notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
 |============================================
 
 [[preferences-input]]
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 07f0f65..07d3cc3 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -209,8 +209,8 @@
 --
 * `DETAILED_LABELS`: detailed label information, including numeric
   values of all existing approvals, recognized label values, values
-  permitted to be set by the current user, and reviewers that may be
-  removed by the current user.
+  permitted to be set by the current user, all reviewers by state, and
+  reviewers that may be removed by the current user.
 --
 
 [[current-revision]]
@@ -414,35 +414,42 @@
           "files": {
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeCache.java": {
               "lines_deleted": 8,
-              "size_delta": -412
+              "size_delta": -412,
+              "size": 7782
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeDetailCache.java": {
               "lines_inserted": 1,
-              "size_delta": 23
+              "size_delta": 23,
+              "size": 6762
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeScreen.java": {
               "lines_inserted": 11,
               "lines_deleted": 19,
-              "size_delta": -298
+              "size_delta": -298,
+              "size": 47023
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java": {
               "lines_inserted": 23,
               "lines_deleted": 20,
-              "size_delta": 132
+              "size_delta": 132,
+              "size": 17727
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarCache.java": {
               "status": "D",
               "lines_deleted": 139,
-              "size_delta": -5512
+              "size_delta": -5512,
+              "size": 13098
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java": {
               "status": "A",
               "lines_inserted": 204,
-              "size_delta": 8345
+              "size_delta": 8345,
+              "size": 8345
             },
             "gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java": {
               "lines_deleted": 9,
-              "size_delta": -343
+              "size_delta": -343,
+              "size": 5385
             }
           }
         }
@@ -634,6 +641,22 @@
         "username": "jroe"
       }
     ],
+    "reviewers": {
+      "REVIEWER": [
+        {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "username": "jdoe"
+        },
+        {
+          "_account_id": 1000097,
+          "name": "Jane Roe",
+          "email": "jane.roe@example.com",
+          "username": "jroe"
+        }
+      ]
+    },
     "messages": [
       {
         "id": "YH-egE",
@@ -1197,6 +1220,13 @@
         "_account_id": 1000000
       }
     ],
+    "reviewers": {
+      "REVIEWER": [
+        {
+          "_account_id": 1000000
+        }
+      ]
+    },
     "current_revision": "9adb9f4c7b40eeee0646e235de818d09164d7379",
     "revisions": {
       "9adb9f4c7b40eeee0646e235de818d09164d7379": {
@@ -1308,6 +1338,13 @@
         "_account_id": 1000000
       }
     ],
+    "reviewers": {
+      "REVIEWER": [
+        {
+          "_account_id": 1000000
+        }
+      ]
+    },
     "current_revision": "1bd7c12a38854a2c6de426feec28800623f492c4",
     "revisions": {
       "1bd7c12a38854a2c6de426feec28800623f492c4": {
@@ -2227,6 +2264,55 @@
   HTTP/1.1 204 No Content
 ----
 
+[[list-votes]]
+=== List Votes
+--
+'GET /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/'
+--
+
+Lists the votes for a specific reviewer of the change.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/ HTTP/1.0
+----
+
+As result a map is returned that maps the label name to the label value.
+The entries in the map are sorted by label name.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  {
+    "Code-Review": -1,
+    "Verified": 1
+    "Work-In-Progress": 1,
+  }
+----
+
+[[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\}]'
+--
+
+Deletes a single vote from a change. Note, that even when the last vote of
+a reviewer is removed the reviewer itself is still listed on the change.
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[revision-endpoints]]
 == Revision Endpoints
 
@@ -2440,6 +2526,20 @@
         "email": "jane.roe@example.com"
       }
     ],
+    "reviewers": {
+      "REVIEWER": [
+        {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com"
+        },
+        {
+          "_account_id": 1000097,
+          "name": "Jane Roe",
+          "email": "jane.roe@example.com"
+        }
+      ]
+    },
     "current_revision": "674ac754f91e64a0efb8087e59a176484bd534d1",
     "revisions": {
       "674ac754f91e64a0efb8087e59a176484bd534d1": {
@@ -3270,12 +3370,14 @@
     "/COMMIT_MSG": {
       "status": "A",
       "lines_inserted": 7,
-      "size_delta": 551
+      "size_delta": 551,
+      "size": 551
     },
     "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": {
       "lines_inserted": 5,
       "lines_deleted": 3,
-      "size_delta": 98
+      "size_delta": 98,
+      "size": 23348
     }
   }
 ----
@@ -3694,6 +3796,10 @@
 === \{draft-id\}
 UUID of a draft comment.
 
+[[label-id]]
+=== \{label-id\}
+The name of the label.
+
 [[file-id]]
 \{file-id\}
 ~~~~~~~~~~~~
@@ -3875,6 +3981,15 @@
 The reviewers that can be removed by the calling user as a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities. +
 Only set if link:#detailed-labels[detailed labels] are requested.
+|`reviewers`          ||
+The reviewers as a map that maps a reviewer state to a list of
+link:rest-api-accounts.html#account-info[AccountInfo] entities.
+Possible reviewer states are `REVIEWER`, `CC` and `REMOVED`. +
+`REVIEWER`: Users with at least one non-zero vote on the change. +
+`CC`: Users that were added to the change, but have not voted. +
+`REMOVED`: Users that were previously reviewers on the change, but have
+been removed. +
+Only set if link:#detailed-labels[detailed labels] are requested.
 |`messages`|optional|
 Messages associated with the change as a list of
 link:#change-message-info[ChangeMessageInfo] entities. +
@@ -4218,6 +4333,8 @@
 Not set for binary files or if no lines were deleted.
 |`size_delta`    ||
 Number of bytes by which the file size increased/decreased.
+|`size`          ||
+File size in bytes.
 |=============================
 
 [[fix-input]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index b1b795c..3a64dc0 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -115,6 +115,7 @@
     "gerrit": {
       "all_projects": "All-Projects",
       "all_users": "All-Users"
+      "doc_search": true
     },
     "sshd": {},
     "suggest": {
@@ -1189,6 +1190,8 @@
 |`all_users_name`    ||
 Name of the link:config-gerrit.html#gerrit.allUsers[project in which
 meta data of all users is stored].
+|`doc_search`        ||
+Whether documentation search is available.
 |`doc_url`           |optional|
 Custom base URL where Gerrit server documentation is located.
 (Documentation may still be available at /Documentation relative to the
diff --git a/VERSION b/VERSION
index e84df7e..573f909 100644
--- a/VERSION
+++ b/VERSION
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = '2.12'
+GERRIT_VERSION = '2.13-SNAPSHOT'
diff --git a/gerrit-acceptance-framework/BUCK b/gerrit-acceptance-framework/BUCK
index d8f0276..9ee1572 100644
--- a/gerrit-acceptance-framework/BUCK
+++ b/gerrit-acceptance-framework/BUCK
@@ -2,13 +2,18 @@
 
 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:junit',
   '//lib/log:impl_log4j',
   '//lib/log:log4j',
diff --git a/gerrit-acceptance-framework/pom.xml b/gerrit-acceptance-framework/pom.xml
index 410c148..2d93315 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.12</version>
+  <version>2.13-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>API for Gerrit Plugins</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 7dbbfb5..3cea4a2 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,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.initSsh;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.block;
+import static com.google.gerrit.testutil.GerritServerTests.isNoteDbTestEnabled;
 
 import com.google.common.base.Function;
 import com.google.common.base.Optional;
@@ -233,13 +233,6 @@
     return cfg.getBoolean("change", null, "submitWholeTopic", false);
   }
 
-  private static boolean isNoteDbTestEnabled() {
-    final String[] RUN_FLAGS = {"yes", "y", "true"};
-    String value = System.getenv("GERRIT_ENABLE_NOTEDB");
-    return value != null &&
-        Arrays.asList(RUN_FLAGS).contains(value.toLowerCase());
-  }
-
   protected void beforeTest(Description description) throws Exception {
     GerritServer.Description classDesc =
       GerritServer.Description.forTestClass(description, configName);
@@ -475,7 +468,8 @@
   }
 
   protected Context setApiUserAnonymous() {
-    return atrScope.newContext(reviewDbProvider, null, anonymousUser.get());
+    return atrScope.set(
+        atrScope.newContext(reviewDbProvider, null, anonymousUser.get()));
   }
 
   protected static Gson newGson() {
@@ -548,6 +542,14 @@
     saveProjectConfig(project, cfg);
   }
 
+  protected PermissionRule block(String permission, AccountGroup.UUID id, String ref)
+      throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    PermissionRule rule = Util.block(cfg, permission, id, ref);
+    saveProjectConfig(project, cfg);
+    return rule;
+  }
+
   protected void saveProjectConfig(Project.NameKey p, ProjectConfig cfg)
       throws Exception {
     MetaDataUpdate md = metaDataUpdateFactory.create(p);
@@ -580,16 +582,14 @@
     projectCache.evict(config.getProject());
   }
 
-  protected void blockRead(Project.NameKey project, String ref) throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    block(cfg, Permission.READ, REGISTERED_USERS, ref);
-    saveProjectConfig(project, cfg);
+  protected void blockRead(String ref) throws Exception {
+    block(Permission.READ, REGISTERED_USERS, ref);
   }
 
   protected void blockForgeCommitter(Project.NameKey project, String ref)
       throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    block(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, ref);
+    Util.block(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, ref);
     saveProjectConfig(project, cfg);
   }
 
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfigs.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfigs.java
index 58bb9f2..e0f9d4a 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfigs.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfigs.java
@@ -23,5 +23,5 @@
 @Target({METHOD})
 @Retention(RUNTIME)
 public @interface GerritConfigs {
-  public GerritConfig[] value();
+  GerritConfig[] value();
 }
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 634db7c..672a5a7 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
@@ -18,6 +18,8 @@
 
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
@@ -67,6 +69,7 @@
     bind(GitRepositoryManager.class)
       .toInstance(new InMemoryRepositoryManager());
 
+    bind(MetricMaker.class).to(DisabledMetricMaker.class);
     bind(DataSourceType.class).to(InMemoryH2Type.class);
     bind(InMemoryDatabase.class).in(SINGLETON);
     bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {})
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java
index f0b9f46..4c77edc 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java
@@ -43,7 +43,6 @@
   private static final String BUCKOUT = "buck-out";
 
   private Path gen;
-  private Path testSite;
   private Path pluginRoot;
   private Path pluginsSitePath;
   private Path pluginSubPath;
@@ -51,6 +50,8 @@
   private String pluginName;
   private boolean standalone;
 
+  protected Path testSite;
+
   @Override
   protected void beforeTest(Description description) throws Exception {
     locatePaths();
@@ -58,9 +59,13 @@
     buildPluginJar();
     createTestSiteDirs();
     copyJarToTestSite();
+    beforeTestServerStarts();
     super.beforeTest(description);
   }
 
+  protected void beforeTestServerStarts() throws Exception {
+  }
+
   protected void setPluginConfigString(String name, String value)
       throws IOException, ConfigInvalidException {
     SitePaths sitePath = new SitePaths(testSite);
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java
index 261b894..d76cb81 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.JSON_MAGIC;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import org.apache.http.HttpStatus;
+
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
@@ -36,4 +39,51 @@
     }
     return reader;
   }
+
+  public void assertStatus(int status) throws Exception {
+    assert_()
+        .withFailureMessage(String.format("Expected status code %d", status))
+        .that(getStatusCode())
+        .isEqualTo(status);
+  }
+
+  public void assertOK() throws Exception {
+    assertStatus(HttpStatus.SC_OK);
+  }
+
+  public void assertNotFound() throws Exception {
+    assertStatus(HttpStatus.SC_NOT_FOUND);
+  }
+
+  public void assertConflict() throws Exception {
+    assertStatus(HttpStatus.SC_CONFLICT);
+  }
+
+  public void assertForbidden() throws Exception {
+    assertStatus(HttpStatus.SC_FORBIDDEN);
+  }
+
+  public void assertNoContent() throws Exception {
+    assertStatus(HttpStatus.SC_NO_CONTENT);
+  }
+
+  public void assertBadRequest() throws Exception {
+    assertStatus(HttpStatus.SC_BAD_REQUEST);
+  }
+
+  public void assertUnprocessableEntity() throws Exception {
+    assertStatus(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+  }
+
+  public void assertMethodNotAllowed() throws Exception {
+    assertStatus(HttpStatus.SC_METHOD_NOT_ALLOWED);
+  }
+
+  public void assertCreated() throws Exception {
+    assertStatus(HttpStatus.SC_CREATED);
+  }
+
+  public void assertPreconditionFailed() throws Exception {
+    assertStatus(HttpStatus.SC_PRECONDITION_FAILED);
+  }
 }
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 39296d0..37d5484 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
@@ -141,6 +141,19 @@
   }
 
   @Test
+  public void getByIntId() throws Exception {
+    AccountInfo info = gApi
+        .accounts()
+        .id("admin")
+        .get();
+    AccountInfo infoByIntId = gApi
+        .accounts()
+        .id(info._accountId)
+        .get();
+    assertThat(info.name).isEqualTo(infoByIntId.name);
+  }
+
+  @Test
   public void self() throws Exception {
     AccountInfo info = gApi
         .accounts()
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 92d7980..9be1865 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
@@ -17,15 +17,18 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.Util.blockLabel;
 import static com.google.gerrit.server.project.Util.category;
 import static com.google.gerrit.server.project.Util.value;
+import static com.google.gerrit.testutil.GerritServerTests.isNoteDbTestEnabled;
 
+import com.google.common.base.Function;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -36,15 +39,19 @@
 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.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
+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.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 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.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
@@ -58,9 +65,11 @@
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.EnumSet;
+import java.util.Iterator;
 import java.util.List;
-import java.util.Set;
+import java.util.Map;
 
 @NoHttpd
 public class ChangeIT extends AbstractDaemonTest {
@@ -88,6 +97,30 @@
   }
 
   @Test
+  public void getAmbiguous() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    String changeId = r1.getChangeId();
+    gApi.changes().id(changeId).get();
+
+    BranchInput b = new BranchInput();
+    b.revision = repo().getRef("HEAD").getObjectId().name();
+    gApi.projects()
+        .name(project.get())
+        .branch("other")
+        .create(b);
+
+    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo,
+        PushOneCommit.SUBJECT, PushOneCommit.FILE_NAME,
+        PushOneCommit.FILE_CONTENT, changeId);
+    PushOneCommit.Result r2 = push2.to("refs/for/other");
+    assertThat(r2.getChangeId()).isEqualTo(changeId);
+
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Multiple changes found for " + changeId);
+    gApi.changes().id(changeId).get();
+  }
+
+  @Test
   public void abandon() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.NEW);
@@ -297,17 +330,6 @@
         .rebase(ri);
   }
 
-  private Set<Account.Id> getReviewers(String changeId) throws Exception {
-    ChangeInfo ci = gApi.changes().id(changeId).get();
-    Set<Account.Id> result = Sets.newHashSet();
-    for (LabelInfo li : ci.labels.values()) {
-      for (ApprovalInfo ai : li.all) {
-        result.add(new Account.Id(ai._accountId));
-      }
-    }
-    return result;
-  }
-
   @Test
   public void addReviewer() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -317,8 +339,20 @@
         .id(r.getChangeId())
         .addReviewer(in);
 
-    assertThat(getReviewers(r.getChangeId()))
-        .containsExactlyElementsIn(ImmutableSet.of(user.id));
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+
+    // When notedb is enabled adding a reviewer records that user as reviewer
+    // in notedb. When notedb is disabled adding a reviewer results in a dummy 0
+    // approval on the change which is treated as CC when the ChangeInfo is
+    // created.
+    Collection<AccountInfo> reviewers = isNoteDbTestEnabled()
+        ? c.reviewers.get(REVIEWER)
+        : c.reviewers.get(CC);
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId)
+        .isEqualTo(user.getId().get());
   }
 
   @Test
@@ -333,16 +367,155 @@
         .revision(r.getCommit().name())
         .submit();
 
-    assertThat(getReviewers(r.getChangeId()))
-      .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId)
+        .isEqualTo(admin.getId().get());
+    assertThat(c.reviewers).doesNotContainKey(CC);
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
     gApi.changes()
         .id(r.getChangeId())
         .addReviewer(in);
-    assertThat(getReviewers(r.getChangeId()))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.id));
+
+    c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    reviewers = c.reviewers.get(REVIEWER);
+    if (isNoteDbTestEnabled()) {
+      // When notedb is enabled adding a reviewer records that user as reviewer
+      // in notedb.
+      assertThat(reviewers).hasSize(2);
+      Iterator<AccountInfo> reviewerIt = reviewers.iterator();
+      assertThat(reviewerIt.next()._accountId)
+          .isEqualTo(admin.getId().get());
+      assertThat(reviewerIt.next()._accountId)
+          .isEqualTo(user.getId().get());
+      assertThat(c.reviewers).doesNotContainKey(CC);
+    } else {
+      // When notedb is disabled adding a reviewer results in a dummy 0 approval
+      // on the change which is treated as CC when the ChangeInfo is created.
+      assertThat(reviewers).hasSize(1);
+      assertThat(reviewers.iterator().next()._accountId)
+          .isEqualTo(admin.getId().get());
+      Collection<AccountInfo> ccs = c.reviewers.get(CC);
+      assertThat(ccs).hasSize(1);
+      assertThat(ccs.iterator().next()._accountId)
+          .isEqualTo(user.getId().get());
+    }
+  }
+
+  @Test
+  public void listVotes() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    Map<String, Short> m = gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(admin.getId().toString())
+        .votes();
+
+    assertThat(m).hasSize(1);
+    assertThat(m).containsEntry("Code-Review", new Short((short)2));
+
+    setApiUser(user);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.dislike());
+
+    m = gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.getId().toString())
+        .votes();
+
+    assertThat(m).hasSize(1);
+    assertThat(m).containsEntry("Code-Review", new Short((short)-1));
+  }
+
+  @Test
+  public void deleteVote() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    setApiUser(user);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.recommend());
+
+    setApiUser(admin);
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(admin.getId().toString())
+        .deleteVote("Code-Review");
+
+    Map<String, Short> m = gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(admin.getId().toString())
+        .votes();
+
+    if (isNoteDbTestEnabled()) {
+      // 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", new Short((short)0));
+    }
+
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+
+    assertThat(Iterables.getLast(c.messages).message).isEqualTo(
+        "Removed Code-Review+2 by Administrator <admin@example.com>\n");
+    if (isNoteDbTestEnabled()) {
+      // 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.
+      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+          .containsExactlyElementsIn(
+              ImmutableSet.of(admin.getId(), user.getId()));
+    } else {
+      // When notedb is disabled users that have only dummy 0 approvals on the
+      // change are returned as CC and not as REVIEWER.
+      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+          .containsExactlyElementsIn(ImmutableSet.of(user.getId()));
+      assertThat(getReviewers(c.reviewers.get(CC)))
+          .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
+    }
+  }
+
+  @Test
+  public void deleteVoteNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("delete not permitted");
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(admin.getId().toString())
+        .deleteVote("Code-Review");
   }
 
   @Test
@@ -644,4 +817,36 @@
     assertThat(rev2.pushCertificate.certificate).isNull();
     assertThat(rev2.pushCertificate.key).isNull();
   }
+
+  @Test
+  public void anonymousRestApi() throws Exception {
+    setApiUserAnonymous();
+    PushOneCommit.Result r = createChange();
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.changeId).isEqualTo(r.getChangeId());
+
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    info = gApi.changes().id(triplet).get();
+    assertThat(info.changeId).isEqualTo(r.getChangeId());
+
+    info = gApi.changes().id(info._number).get();
+    assertThat(info.changeId).isEqualTo(r.getChangeId());
+
+    exception.expect(AuthException.class);
+    gApi.changes()
+        .id(triplet)
+        .current()
+        .review(ReviewInput.approve());
+  }
+
+  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);
+      }
+    });
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/CheckIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/CheckIT.java
index 475803a..37a5071 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/CheckIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/CheckIT.java
@@ -25,6 +25,10 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.inject.Inject;
 
 import org.junit.Test;
 
@@ -33,6 +37,15 @@
 
 @NoHttpd
 public class CheckIT extends AbstractDaemonTest {
+  @Inject
+  private ChangeControl.GenericFactory changeControlFactory;
+
+  @Inject
+  private IdentifiedUser.GenericFactory userFactory;
+
+  @Inject
+  private ChangeUpdate.Factory changeUpdateFactory;
+
   // Most types of tests belong in ConsistencyCheckerTest; these mostly just
   // test paths outside of ConsistencyChecker, like API wiring.
   @Test
@@ -66,6 +79,12 @@
     Change c = getChange(r);
     c.setStatus(Change.Status.NEW);
     db.changes().update(Collections.singleton(c));
+    ChangeUpdate changeUpdate =
+        changeUpdateFactory.create(
+            changeControlFactory.controlFor(
+                c, userFactory.create(admin.id)));
+    changeUpdate.setStatus(Change.Status.NEW);
+    changeUpdate.commit();
     indexer.index(db, c);
 
     ChangeInfo info = gApi.changes()
@@ -81,6 +100,11 @@
     assertThat(info.problems).hasSize(1);
     assertThat(info.problems.get(0).status).isEqualTo(ProblemInfo.Status.FIXED);
     assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+
+    info = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
   }
 
   private Change getChange(PushOneCommit.Result r) throws Exception {
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 3e970e4..3cddcd9 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
@@ -56,6 +56,7 @@
 import java.io.ByteArrayOutputStream;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
@@ -159,7 +160,11 @@
     assertThat(orig.get().messages).hasSize(1);
     ChangeApi cherry = orig.revision(r.getCommit().name())
         .cherryPick(in);
-    assertThat(orig.get().messages).hasSize(2);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes()
+        .id(project.get() + "~master~" + r.getChangeId())
+        .get().messages;
+    assertThat(messages).hasSize(2);
 
     String cherryPickedRevision = cherry.get().currentRevision;
     String expectedMessage = String.format(
@@ -167,7 +172,7 @@
         "This patchset was cherry picked to branch %s as commit %s",
         in.destination, cherryPickedRevision);
 
-    Iterator<ChangeMessageInfo> origIt = orig.get().messages.iterator();
+    Iterator<ChangeMessageInfo> origIt = messages.iterator();
     origIt.next();
     assertThat(origIt.next().message).isEqualTo(expectedMessage);
 
@@ -279,7 +284,11 @@
     assertThat(orig.get().messages).hasSize(1);
     ChangeApi cherry = orig.revision(r.getCommit().name())
         .cherryPick(in);
-    assertThat(orig.get().messages).hasSize(2);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes()
+        .id(project.get() + "~master~" + r.getChangeId())
+        .get().messages;
+    assertThat(messages).hasSize(2);
 
     assertThat(cherry.get().subject).contains(in.message);
     cherry.current().review(ReviewInput.approve());
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 ed20e24..f4e7cb7 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
@@ -17,13 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.apache.http.HttpStatus.SC_CONFLICT;
-import static org.apache.http.HttpStatus.SC_FORBIDDEN;
-import static org.apache.http.HttpStatus.SC_NOT_FOUND;
-import static org.apache.http.HttpStatus.SC_NO_CONTENT;
-import static org.apache.http.HttpStatus.SC_OK;
 
 import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
@@ -58,6 +52,7 @@
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.Util;
+import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.gson.stream.JsonReader;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -66,9 +61,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeUtils;
-import org.joda.time.DateTimeUtils.MillisProvider;
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
@@ -81,7 +73,6 @@
 import java.util.Date;
 import java.util.Iterator;
 import java.util.List;
-import java.util.concurrent.atomic.AtomicLong;
 
 public class ChangeEditIT extends AbstractDaemonTest {
 
@@ -114,20 +105,12 @@
 
   @BeforeClass
   public static void setTimeForTesting() {
-    final long clockStepMs = MILLISECONDS.convert(1, SECONDS);
-    final AtomicLong clockMs = new AtomicLong(
-        new DateTime(2009, 9, 30, 17, 0, 0).getMillis());
-    DateTimeUtils.setCurrentMillisProvider(new MillisProvider() {
-      @Override
-      public long getMillis() {
-        return clockMs.getAndAdd(clockStepMs);
-      }
-    });
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
   }
 
   @AfterClass
   public static void restoreTime() {
-    DateTimeUtils.setCurrentMillisSystem();
+    TestTimeUtil.useSystemTime();
   }
 
   @Before
@@ -185,8 +168,7 @@
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
             RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    RestResponse r = adminSession.post(urlPublish());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminSession.post(urlPublish()).assertNoContent();
     edit = editUtil.byChange(change);
     assertThat(edit.isPresent()).isFalse();
     PatchSet newCurrentPatchSet = getCurrentPatchSet(changeId);
@@ -204,8 +186,7 @@
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
             RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    RestResponse r = adminSession.delete(urlEdit());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminSession.delete(urlEdit()).assertNoContent();
     edit = editUtil.byChange(change);
     assertThat(edit.isPresent()).isFalse();
   }
@@ -219,11 +200,9 @@
     assertThat(
         modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
             RestSession.newRawInput(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
-    RestResponse r = adminSession.post(urlPublish());
-    assertThat(r.getStatusCode()).isEqualTo(SC_FORBIDDEN);
+    adminSession.post(urlPublish()).assertForbidden();
     setUseContributorAgreements(InheritableBoolean.FALSE);
-    r = adminSession.post(urlPublish());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminSession.post(urlPublish()).assertNoContent();
   }
 
   @Test
@@ -260,8 +239,7 @@
     assertThat(edit.getBasePatchSet().getPatchSetId()).isEqualTo(
         current.getPatchSetId() - 1);
     Date beforeRebase = edit.getEditCommit().getCommitterIdent().getWhen();
-    RestResponse r = adminSession.post(urlRebase());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminSession.post(urlRebase()).assertNoContent();
     edit = editUtil.byChange(change).get();
     assertByteArray(fileUtil.getContent(projectCache.get(edit.getChange().getProject()),
         ObjectId.fromString(edit.getRevision().get()), FILE_NAME), CONTENT_NEW);
@@ -287,8 +265,7 @@
         pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, FILE_NAME,
             new String(CONTENT_NEW2), changeId2);
     push.to("refs/for/master").assertOkStatus();
-    RestResponse r = adminSession.post(urlRebase());
-    assertThat(r.getStatusCode()).isEqualTo(SC_CONFLICT);
+    adminSession.post(urlRebase()).assertConflict();
   }
 
   @Test
@@ -370,24 +347,21 @@
 
   @Test
   public void updateMessageRest() throws Exception {
-    assertThat(adminSession.get(urlEditMessage()).getStatusCode())
-        .isEqualTo(SC_NOT_FOUND);
+    adminSession.get(urlEditMessage()).assertNotFound();
     EditMessage.Input in = new EditMessage.Input();
     in.message = String.format("New commit message\n\n" +
         CONTENT_NEW2_STR + "\n\nChange-Id: %s\n",
         change.getKey());
-    assertThat(adminSession.put(urlEditMessage(), in).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
+    adminSession.put(urlEditMessage(), in).assertNoContent();
     RestResponse r = adminSession.getJsonAccept(urlEditMessage());
-    assertThat(r.getStatusCode()).isEqualTo(SC_OK);
+    r.assertOK();
     assertThat(readContentFromJson(r)).isEqualTo(in.message);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertThat(edit.get().getEditCommit().getFullMessage())
         .isEqualTo(in.message);
     in.message = String.format("New commit message2\n\nChange-Id: %s\n",
         change.getKey());
-    assertThat(adminSession.put(urlEditMessage(), in).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
+    adminSession.put(urlEditMessage(), in).assertNoContent();
     edit = editUtil.byChange(change);
     assertThat(edit.get().getEditCommit().getFullMessage())
         .isEqualTo(in.message);
@@ -400,8 +374,7 @@
 
   @Test
   public void retrieveEdit() throws Exception {
-    RestResponse r = adminSession.get(urlEdit());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminSession.get(urlEdit()).assertNoContent();
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RestSession.newRawInput(CONTENT_NEW)))
@@ -414,8 +387,7 @@
     edit = editUtil.byChange(change);
     editUtil.delete(edit.get());
 
-    r = adminSession.get(urlEdit());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminSession.get(urlEdit()).assertNoContent();
   }
 
   @Test
@@ -460,8 +432,7 @@
 
   @Test
   public void createEditByDeletingExistingFileRest() throws Exception {
-    RestResponse r = adminSession.delete(urlEditFile());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminSession.delete(urlEditFile()).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     exception.expect(ResourceNotFoundException.class);
     fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
@@ -470,15 +441,13 @@
 
   @Test
   public void deletingNonExistingEditRest() throws Exception {
-    RestResponse r = adminSession.delete(urlEdit());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NOT_FOUND);
+    adminSession.delete(urlEdit()).assertNotFound();
   }
 
   @Test
   public void deleteExistingFileRest() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(adminSession.delete(urlEditFile()).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminSession.delete(urlEditFile()).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     exception.expect(ResourceNotFoundException.class);
     fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
@@ -527,8 +496,7 @@
     Post.Input in = new Post.Input();
     in.oldPath = FILE_NAME;
     in.newPath = FILE_NAME3;
-    assertThat(adminSession.post(urlEdit(), in).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminSession.post(urlEdit(), in).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME3), CONTENT_OLD);
@@ -541,8 +509,7 @@
   public void restoreDeletedFileInPatchSetRest() throws Exception {
     Post.Input in = new Post.Input();
     in.restorePath = FILE_NAME;
-    assertThat(adminSession.post(urlEdit2(), in).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminSession.post(urlEdit2(), in).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change2);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_OLD);
@@ -568,14 +535,12 @@
   public void createAndChangeEditInOneRequestRest() throws Exception {
     Put.Input in = new Put.Input();
     in.content = RestSession.newRawInput(CONTENT_NEW);
-    assertThat(adminSession.putRaw(urlEditFile(), in.content).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
+    adminSession.putRaw(urlEditFile(), in.content).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW);
     in.content = RestSession.newRawInput(CONTENT_NEW2);
-    assertThat(adminSession.putRaw(urlEditFile(), in.content).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
+    adminSession.putRaw(urlEditFile(), in.content).assertNoContent();
     edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW2);
@@ -586,8 +551,7 @@
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
     Put.Input in = new Put.Input();
     in.content = RestSession.newRawInput(CONTENT_NEW);
-    assertThat(adminSession.putRaw(urlEditFile(), in.content).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
+    adminSession.putRaw(urlEditFile(), in.content).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW);
@@ -596,8 +560,7 @@
   @Test
   public void emptyPutRequest() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(adminSession.put(urlEditFile()).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminSession.put(urlEditFile()).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), "".getBytes());
@@ -605,8 +568,7 @@
 
   @Test
   public void createEmptyEditRest() throws Exception {
-    assertThat(adminSession.post(urlEdit()).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminSession.post(urlEdit()).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_OLD);
@@ -616,14 +578,13 @@
   public void getFileContentRest() throws Exception {
     Put.Input in = new Put.Input();
     in.content = RestSession.newRawInput(CONTENT_NEW);
-    assertThat(adminSession.putRaw(urlEditFile(), in.content).getStatusCode())
-        .isEqualTo(SC_NO_CONTENT);
+    adminSession.putRaw(urlEditFile(), in.content).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
     assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RestSession.newRawInput(CONTENT_NEW2)))
         .isEqualTo(RefUpdate.Result.FORCED);
     edit = editUtil.byChange(change);
     RestResponse r = adminSession.getJsonAccept(urlEditFile());
-    assertThat(r.getStatusCode()).isEqualTo(SC_OK);
+    r.assertOK();
     assertThat(readContentFromJson(r)).isEqualTo(
         StringUtils.newStringUtf8(CONTENT_NEW2));
   }
@@ -631,11 +592,9 @@
   @Test
   public void getFileNotFoundRest() throws Exception {
     assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(adminSession.delete(urlEditFile()).getStatusCode()).isEqualTo(
-        SC_NO_CONTENT);
+    adminSession.delete(urlEditFile()).assertNoContent();
     Optional<ChangeEdit> edit = editUtil.byChange(change);
-    RestResponse r = adminSession.get(urlEditFile());
-    assertThat(r.getStatusCode()).isEqualTo(SC_NO_CONTENT);
+    adminSession.get(urlEditFile()).assertNoContent();
     exception.expect(ResourceNotFoundException.class);
     fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
         ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME);
@@ -832,9 +791,9 @@
         + "/edit:rebase";
   }
 
-  private EditInfo toEditInfo(boolean files) throws IOException {
+  private EditInfo toEditInfo(boolean files) throws Exception {
     RestResponse r = adminSession.get(files ? urlGetFiles() : urlEdit());
-    assertThat(r.getStatusCode()).isEqualTo(SC_OK);
+    r.assertOK();
     return newGson().fromJson(r.getReader(), EditInfo.class);
   }
 
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 900d85a..73a02a5 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
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableSet;
@@ -26,23 +26,28 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.git.MetaDataUpdate;
+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.testutil.TestTimeUtil;
 
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeUtils;
-import org.joda.time.DateTimeUtils.MillisProvider;
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
 import java.util.Set;
-import java.util.concurrent.atomic.AtomicLong;
 
 public abstract class AbstractPushForReview extends AbstractDaemonTest {
   protected enum Protocol {
@@ -51,28 +56,39 @@
   }
 
   private String sshUrl;
+  private LabelType patchSetLock;
 
   @BeforeClass
   public static void setTimeForTesting() {
-    final long clockStepMs = MILLISECONDS.convert(1, SECONDS);
-    final AtomicLong clockMs = new AtomicLong(
-        new DateTime(2009, 9, 30, 17, 0, 0).getMillis());
-    DateTimeUtils.setCurrentMillisProvider(new MillisProvider() {
-      @Override
-      public long getMillis() {
-        return clockMs.getAndAdd(clockStepMs);
-      }
-    });
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
   }
 
   @AfterClass
   public static void restoreTime() {
-    DateTimeUtils.setCurrentMillisSystem();
+    TestTimeUtil.useSystemTime();
   }
 
   @Before
   public void setUp() throws Exception {
     sshUrl = sshSession.getUrl();
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    patchSetLock = Util.patchSetLock();
+    cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
+    AccountGroup.UUID anonymousUsers =
+        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, anonymousUsers,
+        "refs/heads/*");
+    saveProjectConfig(cfg);
+    grant(Permission.LABEL + "Patch-Set-Lock", project, "refs/heads/*");
+  }
+
+  private void saveProjectConfig(ProjectConfig cfg) throws Exception {
+    MetaDataUpdate md = metaDataUpdateFactory.create(project);
+    try {
+      cfg.commit(md);
+    } finally {
+      md.close();
+    }
   }
 
   protected void selectProtocol(Protocol p) throws Exception {
@@ -276,6 +292,18 @@
   }
 
   @Test
+  public void testPushNewPatchsetToPatchSetLockedChange() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
+        PushOneCommit.SUBJECT, "b.txt", "anotherContent", r.getChangeId());
+    revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
+    r = push.to("refs/for/master");
+    r.assertErrorStatus("cannot replace " + r.getChange().change().getChangeId()
+        + ". Change is patch set locked.");
+  }
+
+  @Test
   public void testPushForMasterWithApprovals_MissingLabel() throws Exception {
       PushOneCommit.Result r = pushTo("refs/for/master/%l=Verify");
       r.assertErrorStatus("label \"Verify\" is not a configured label");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
index 27b8b0a..41f47a2 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
@@ -15,13 +15,11 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-import static com.google.gerrit.server.project.Util.block;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.server.git.ProjectConfig;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -31,9 +29,7 @@
 
   @Before
   public void setUp() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    block(cfg, Permission.PUSH, ANONYMOUS_USERS, "refs/drafts/*");
-    saveProjectConfig(project, cfg);
+    block(Permission.PUSH, ANONYMOUS_USERS, "refs/drafts/*");
   }
 
   @Test
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 3e7c2bf..768c923 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
@@ -34,7 +34,6 @@
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class CapabilitiesIT extends AbstractDaemonTest {
@@ -53,7 +52,7 @@
     try {
       RestResponse r =
           userSession.get("/accounts/self/capabilities");
-      assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+      r.assertOK();
       CapabilityInfo info = (new Gson()).fromJson(r.getReader(),
           new TypeToken<CapabilityInfo>() {}.getType());
       for (String c : GlobalCapability.getAllNames()) {
@@ -81,7 +80,7 @@
   public void testCapabilitiesAdmin() throws Exception {
     RestResponse r =
         adminSession.get("/accounts/self/capabilities");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     CapabilityInfo info = (new Gson()).fromJson(r.getReader(),
         new TypeToken<CapabilityInfo>() {}.getType());
     for (String c : GlobalCapability.getAllNames()) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/DiffPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/DiffPreferencesIT.java
index 5b2e7ba..9a66958 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/DiffPreferencesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/DiffPreferencesIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
@@ -23,23 +22,21 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.client.Theme;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class DiffPreferencesIT extends AbstractDaemonTest {
   @Test
   public void getDiffPreferencesOfNonExistingAccount_NotFound()
       throws Exception {
-    assertEquals(HttpStatus.SC_NOT_FOUND,
-        adminSession.get("/accounts/non-existing/preferences.diff")
-        .getStatusCode());
+    adminSession.get("/accounts/non-existing/preferences.diff")
+        .assertNotFound();
   }
 
   @Test
   public void getDiffPreferences() throws Exception {
     RestResponse r = adminSession.get("/accounts/" + admin.email
         + "/preferences.diff");
-    assertEquals(HttpStatus.SC_OK, r.getStatusCode());
+    r.assertOK();
     DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
     DiffPreferencesInfo o =
         newGson().fromJson(r.getReader(), DiffPreferencesInfo.class);
@@ -98,7 +95,7 @@
 
     RestResponse r = adminSession.put("/accounts/" + admin.email
         + "/preferences.diff", i);
-    assertEquals(HttpStatus.SC_OK, r.getStatusCode());
+    r.assertOK();
     DiffPreferencesInfo o = newGson().fromJson(r.getReader(),
         DiffPreferencesInfo.class);
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EditPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EditPreferencesIT.java
index cf89c5a..627d9bd 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EditPreferencesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EditPreferencesIT.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.extensions.client.KeyMapType;
 import com.google.gerrit.extensions.client.Theme;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.io.IOException;
@@ -32,7 +31,7 @@
   public void getSetEditPreferences() throws Exception {
     String endPoint = "/accounts/" + admin.email + "/preferences.edit";
     RestResponse r = adminSession.get(endPoint);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     EditPreferencesInfo out = getEditPrefInfo(r);
 
     assertThat(out.lineLength).isEqualTo(100);
@@ -62,22 +61,20 @@
     out.theme = Theme.TWILIGHT;
     out.keyMapType = KeyMapType.EMACS;
 
-    r = adminSession.put(endPoint, out);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    adminSession.put(endPoint, out).assertNoContent();
 
     r = adminSession.get(endPoint);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     EditPreferencesInfo info = getEditPrefInfo(r);
     assertEditPreferences(info, out);
 
     // Partially filled input record
     EditPreferencesInfo in = new EditPreferencesInfo();
     in.tabSize = 42;
-    r = adminSession.put(endPoint, in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    adminSession.put(endPoint, in).assertNoContent();
 
     r = adminSession.get(endPoint);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     info = getEditPrefInfo(r);
     out.tabSize = in.tabSize;
     assertEditPreferences(info, out);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
index 2543095..9ba7810 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
@@ -26,7 +26,6 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.util.Collections;
@@ -41,7 +40,7 @@
     in.username = "myUsername";
     RestResponse r =
         adminSession.put("/accounts/" + createUser().get() + "/username", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     assertThat(newGson().fromJson(r.getReader(), String.class)).isEqualTo(
         in.username);
   }
@@ -50,25 +49,25 @@
   public void setExisting_Conflict() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = admin.username;
-    RestResponse r =
-        adminSession.put("/accounts/" + createUser().get() + "/username", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    adminSession
+        .put("/accounts/" + createUser().get() + "/username", in)
+        .assertConflict();
   }
 
   @Test
   public void setNew_MethodNotAllowed() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = "newUsername";
-    RestResponse r =
-        adminSession.put("/accounts/" + admin.username + "/username", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_METHOD_NOT_ALLOWED);
+    adminSession
+        .put("/accounts/" + admin.username + "/username", in)
+        .assertMethodNotAllowed();
   }
 
   @Test
   public void delete_MethodNotAllowed() throws Exception {
-    RestResponse r =
-        adminSession.put("/accounts/" + admin.username + "/username");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_METHOD_NOT_ALLOWED);
+    adminSession
+        .put("/accounts/" + admin.username + "/username")
+        .assertMethodNotAllowed();
   }
 
   private Account.Id createUser() throws OrmException {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index d726d70..91f2962 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -23,41 +22,28 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.TestTimeUtil;
 
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeUtils;
-import org.joda.time.DateTimeUtils.MillisProvider;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import java.util.Iterator;
-import java.util.concurrent.atomic.AtomicLong;
 
 @RunWith(ConfigSuite.class)
 public class ChangeMessagesIT extends AbstractDaemonTest {
   private String systemTimeZone;
-  private volatile long clockStepMs;
 
   @Before
   public void setTimeForTesting() {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    clockStepMs = MILLISECONDS.convert(1, SECONDS);
-    final AtomicLong clockMs = new AtomicLong(
-        new DateTime(2009, 9, 30, 17, 0, 0).getMillis());
-
-    DateTimeUtils.setCurrentMillisProvider(new MillisProvider() {
-      @Override
-      public long getMillis() {
-        return clockMs.getAndAdd(clockStepMs);
-      }
-    });
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
   }
 
   @After
   public void resetTime() {
-    DateTimeUtils.setCurrentMillisSystem();
+    TestTimeUtil.useSystemTime();
     System.setProperty("user.timezone", systemTimeZone);
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index 2229577..b04dc6d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -76,11 +76,8 @@
   }
 
   private void assertApproveFails(TestAccount a, String changeId) throws Exception {
-    try {
-      approve(a, changeId);
-    } catch (AuthException expected) {
-      // Expected.
-    }
+    exception.expect(AuthException.class);
+    approve(a, changeId);
   }
 
   private void grantApproveToChangeOwner() throws IOException,
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index d0bcfd9..9edf9b2 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -16,6 +16,7 @@
 
 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;
@@ -26,8 +27,11 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.TestTimeUtil;
 
 import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
 import org.junit.Test;
 
 @NoHttpd
@@ -37,6 +41,17 @@
     return allowDraftsDisabledConfig();
   }
 
+  @BeforeClass
+  public static void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @AfterClass
+  public static void restoreTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+
   @Test
   public void createEmptyChange_MissingBranch() throws Exception {
     ChangeInfo ci = new ChangeInfo();
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 47d071f..3f2107c 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
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.io.IOException;
@@ -45,7 +44,7 @@
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
     RestResponse r = deletePatchSet(changeId, ps, adminSession);
     assertThat(r.getEntityContent()).isEqualTo("Patch set is not a draft");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    r.assertConflict();
   }
 
   @Test
@@ -58,7 +57,7 @@
     assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
     RestResponse r = deletePatchSet(changeId, ps, userSession);
     assertThat(r.getEntityContent()).isEqualTo("Not found: " + changeId);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    r.assertNotFound();
   }
 
   @Test
@@ -69,12 +68,10 @@
     ChangeInfo c = get(triplet);
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
-    RestResponse r = deletePatchSet(changeId, ps, adminSession);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    deletePatchSet(changeId, ps, adminSession).assertNoContent();
     assertThat(getChange(changeId).patchSets()).hasSize(1);
     ps = getCurrentPatchSet(changeId);
-    r = deletePatchSet(changeId, ps, adminSession);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    deletePatchSet(changeId, ps, adminSession).assertNoContent();
     assertThat(queryProvider.get().byKeyPrefix(changeId)).isEmpty();
   }
 
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 2707507..1fc91d2 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
@@ -29,7 +29,6 @@
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gwtorm.server.OrmException;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
@@ -51,8 +50,9 @@
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
     RestResponse response = deleteChange(changeId, adminSession);
-    assertThat(response.getEntityContent()).isEqualTo("Change is not a draft");
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Change is not a draft: " + c._number);
+    response.assertConflict();
   }
 
   @Test
@@ -65,8 +65,7 @@
     ChangeInfo c = get(triplet);
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
-    RestResponse response = deleteChange(changeId, adminSession);
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    deleteChange(changeId, adminSession).assertNoContent();
 
     exception.expect(ResourceNotFoundException.class);
     get(triplet);
@@ -83,8 +82,7 @@
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
     assertThat(c.revisions.get(c.currentRevision).draft).isTrue();
-    RestResponse response = publishChange(changeId);
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    publishChange(changeId).assertNoContent();
     c = get(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
     assertThat(c.revisions.get(c.currentRevision).draft).isNull();
@@ -100,8 +98,7 @@
     ChangeInfo c = get(triplet);
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
-    RestResponse response = publishPatchSet(changeId);
-    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    publishPatchSet(changeId).assertNoContent();
     assertThat(get(triplet).status).isEqualTo(ChangeStatus.NEW);
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 405f8e5..7035bf9 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -14,27 +14,25 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class IndexChangeIT extends AbstractDaemonTest {
   @Test
   public void indexChange() throws Exception {
     String changeId = createChange().getChangeId();
-    RestResponse r = adminSession.post("/changes/" + changeId + "/index/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    adminSession
+        .post("/changes/" + changeId + "/index/")
+        .assertNoContent();
   }
 
   @Test
   public void indexChangeOnNonVisibleBranch() throws Exception {
     String changeId = createChange().getChangeId();
-    blockRead(project, "refs/heads/master");
-    RestResponse r = userSession.post("/changes/" + changeId + "/index/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    blockRead("refs/heads/master");
+    userSession
+        .post("/changes/" + changeId + "/index/")
+        .assertNotFound();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
index 7e68a03..7754a53 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.config.PostCaches;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.util.Arrays;
@@ -39,7 +38,7 @@
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
 
     r = adminSession.post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     r.consume();
 
     r = adminSession.get("/config/server/caches/project_list");
@@ -49,16 +48,16 @@
 
   @Test
   public void flushAll_Forbidden() throws Exception {
-    RestResponse r = userSession.post("/config/server/caches/",
-        new PostCaches.Input(FLUSH_ALL));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userSession.post("/config/server/caches/",
+        new PostCaches.Input(FLUSH_ALL)).assertForbidden();
   }
 
   @Test
   public void flushAll_BadRequest() throws Exception {
-    RestResponse r = adminSession.post("/config/server/caches/",
-        new PostCaches.Input(FLUSH_ALL, Arrays.asList("projects")));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminSession
+        .post("/config/server/caches/",
+            new PostCaches.Input(FLUSH_ALL, Arrays.asList("projects")))
+        .assertBadRequest();
   }
 
   @Test
@@ -73,7 +72,7 @@
 
     r = adminSession.post("/config/server/caches/",
         new PostCaches.Input(FLUSH, Arrays.asList("accounts", "project_list")));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     r.consume();
 
     r = adminSession.get("/config/server/caches/project_list");
@@ -87,16 +86,18 @@
 
   @Test
   public void flush_Forbidden() throws Exception {
-    RestResponse r = userSession.post("/config/server/caches/",
-        new PostCaches.Input(FLUSH, Arrays.asList("projects")));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userSession
+        .post("/config/server/caches/",
+            new PostCaches.Input(FLUSH, Arrays.asList("projects")))
+        .assertForbidden();
   }
 
   @Test
   public void flush_BadRequest() throws Exception {
-    RestResponse r = adminSession.post("/config/server/caches/",
-        new PostCaches.Input(FLUSH));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminSession
+        .post("/config/server/caches/",
+            new PostCaches.Input(FLUSH))
+        .assertBadRequest();
   }
 
   @Test
@@ -107,7 +108,7 @@
 
     r = adminSession.post("/config/server/caches/",
         new PostCaches.Input(FLUSH, Arrays.asList("projects", "unprocessable")));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+    r.assertUnprocessableEntity();
     r.consume();
 
     r = adminSession.get("/config/server/caches/projects");
@@ -122,12 +123,13 @@
     try {
       RestResponse r = userSession.post("/config/server/caches/",
           new PostCaches.Input(FLUSH, Arrays.asList("projects")));
-      assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+      r.assertOK();
       r.consume();
 
-      r = userSession.post("/config/server/caches/",
-          new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")));
-      assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+      userSession
+          .post("/config/server/caches/",
+              new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")))
+          .assertForbidden();
     } finally {
       removeGlobalCapabilities(REGISTERED_USERS,
           GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
index 9d8320ad..0638637 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
@@ -14,17 +14,13 @@
 
 package com.google.gerrit.acceptance.rest.config;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.server.config.ConfirmEmail;
 import com.google.gerrit.server.mail.EmailTokenVerifier;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.inject.Inject;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
@@ -44,23 +40,26 @@
   public void confirm() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
     in.token = emailTokenVerifier.encode(admin.getId(), "new.mail@example.com");
-    RestResponse r = adminSession.put("/config/server/email.confirm", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    adminSession
+        .put("/config/server/email.confirm", in)
+        .assertNoContent();
   }
 
   @Test
   public void confirmForOtherUser_UnprocessableEntity() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
     in.token = emailTokenVerifier.encode(user.getId(), "new.mail@example.com");
-    RestResponse r = adminSession.put("/config/server/email.confirm", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+    adminSession
+        .put("/config/server/email.confirm", in)
+        .assertUnprocessableEntity();
   }
 
   @Test
   public void confirmInvalidToken_UnprocessableEntity() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
     in.token = "invalidToken";
-    RestResponse r = adminSession.put("/config/server/email.confirm", in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+    adminSession
+        .put("/config/server/email.confirm", in)
+        .assertUnprocessableEntity();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
index bb63928..0113672 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class FlushCacheIT extends AbstractDaemonTest {
@@ -34,7 +33,7 @@
     assertThat(result.entries.mem).isGreaterThan((long)0);
 
     r = adminSession.post("/config/server/caches/groups/flush");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     r.consume();
 
     r = adminSession.get("/config/server/caches/groups");
@@ -44,26 +43,30 @@
 
   @Test
   public void flushCache_Forbidden() throws Exception {
-    RestResponse r = userSession.post("/config/server/caches/accounts/flush");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userSession
+        .post("/config/server/caches/accounts/flush")
+        .assertForbidden();
   }
 
   @Test
   public void flushCache_NotFound() throws Exception {
-    RestResponse r = adminSession.post("/config/server/caches/nonExisting/flush");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    adminSession
+        .post("/config/server/caches/nonExisting/flush")
+        .assertNotFound();
   }
 
   @Test
   public void flushCacheWithGerritPrefix() throws Exception {
-    RestResponse r = adminSession.post("/config/server/caches/gerrit-accounts/flush");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    adminSession
+        .post("/config/server/caches/gerrit-accounts/flush")
+        .assertOK();
   }
 
   @Test
   public void flushWebSessionsCache() throws Exception {
-    RestResponse r = adminSession.post("/config/server/caches/web_sessions/flush");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    adminSession
+        .post("/config/server/caches/web_sessions/flush")
+        .assertOK();
   }
 
   @Test
@@ -72,11 +75,12 @@
         GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
     try {
       RestResponse r = userSession.post("/config/server/caches/accounts/flush");
-      assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+      r.assertOK();
       r.consume();
 
-      r = userSession.post("/config/server/caches/web_sessions/flush");
-      assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+      userSession
+          .post("/config/server/caches/web_sessions/flush")
+          .assertForbidden();
     } finally {
       removeGlobalCapabilities(REGISTERED_USERS,
           GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
index 02a1b73..ff8a3b9 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.config.ListCaches.CacheType;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class GetCacheIT extends AbstractDaemonTest {
@@ -29,7 +28,7 @@
   @Test
   public void getCache() throws Exception {
     RestResponse r = adminSession.get("/config/server/caches/accounts");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
 
     assertThat(result.name).isEqualTo("accounts");
@@ -45,26 +44,29 @@
 
     userSession.get("/config/server/version").consume();
     r = adminSession.get("/config/server/caches/accounts");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     result = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(result.entries.mem).isEqualTo(2);
   }
 
   @Test
   public void getCache_Forbidden() throws Exception {
-    RestResponse r = userSession.get("/config/server/caches/accounts");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userSession
+        .get("/config/server/caches/accounts")
+        .assertForbidden();
   }
 
   @Test
   public void getCache_NotFound() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/nonExisting");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    adminSession
+        .get("/config/server/caches/nonExisting")
+        .assertNotFound();
   }
 
   @Test
   public void getCacheWithGerritPrefix() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/gerrit-accounts");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    adminSession
+        .get("/config/server/caches/gerrit-accounts")
+        .assertOK();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
index acd900c..8455062 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.util.List;
@@ -32,7 +31,7 @@
   public void getTask() throws Exception {
     RestResponse r =
         adminSession.get("/config/server/tasks/" + getLogFileCompressorTaskId());
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     TaskInfo info =
         newGson().fromJson(r.getReader(),
             new TypeToken<TaskInfo>() {}.getType());
@@ -44,9 +43,9 @@
 
   @Test
   public void getTask_NotFound() throws Exception {
-    RestResponse r =
-        userSession.get("/config/server/tasks/" + getLogFileCompressorTaskId());
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    userSession
+        .get("/config/server/tasks/" + getLogFileCompressorTaskId())
+        .assertNotFound();
   }
 
   private String getLogFileCompressorTaskId() throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
index 11c61c6..88b93c8 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.util.List;
@@ -37,7 +36,7 @@
     assertThat(taskCount).isGreaterThan(0);
 
     r = adminSession.delete("/config/server/tasks/" + result.get(0).id);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
+    r.assertNoContent();
     r.consume();
 
     r = adminSession.get("/config/server/tasks/");
@@ -54,8 +53,9 @@
     r.consume();
     assertThat(result.size()).isGreaterThan(0);
 
-    r = userSession.delete("/config/server/tasks/" + result.get(0).id);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    userSession
+        .delete("/config/server/tasks/" + result.get(0).id)
+        .assertNotFound();
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
index fb89e1b..964c759 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.config.ListCaches.CacheType;
 import com.google.gson.reflect.TypeToken;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.util.Base64;
 import org.junit.Test;
 
@@ -37,7 +36,7 @@
   @Test
   public void listCaches() throws Exception {
     RestResponse r = adminSession.get("/config/server/caches/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     Map<String, CacheInfo> result =
         newGson().fromJson(r.getReader(),
             new TypeToken<Map<String, CacheInfo>>() {}.getType());
@@ -56,7 +55,7 @@
 
     userSession.get("/config/server/version").consume();
     r = adminSession.get("/config/server/caches/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     result = newGson().fromJson(r.getReader(),
         new TypeToken<Map<String, CacheInfo>>() {}.getType());
     assertThat(result.get("accounts").entries.mem).isEqualTo(2);
@@ -64,14 +63,15 @@
 
   @Test
   public void listCaches_Forbidden() throws Exception {
-    RestResponse r = userSession.get("/config/server/caches/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userSession
+        .get("/config/server/caches/")
+        .assertForbidden();
   }
 
   @Test
   public void listCacheNames() throws Exception {
     RestResponse r = adminSession.get("/config/server/caches/?format=LIST");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     List<String> result =
         newGson().fromJson(r.getReader(),
             new TypeToken<List<String>>() {}.getType());
@@ -83,7 +83,7 @@
   @Test
   public void listCacheNamesTextList() throws Exception {
     RestResponse r = adminSession.get("/config/server/caches/?format=TEXT_LIST");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     String result = new String(Base64.decode(r.getEntityContent()), UTF_8.name());
     List<String> list = Arrays.asList(result.split("\n"));
     assertThat(list).contains("accounts");
@@ -93,7 +93,8 @@
 
   @Test
   public void listCaches_BadRequest() throws Exception {
-    RestResponse r = adminSession.get("/config/server/caches/?format=NONSENSE");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminSession
+        .get("/config/server/caches/?format=NONSENSE")
+        .assertBadRequest();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
index 58f3361..0b2c6cc 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.util.List;
@@ -31,7 +30,7 @@
   @Test
   public void listTasks() throws Exception {
     RestResponse r = adminSession.get("/config/server/tasks/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     List<TaskInfo> result =
         newGson().fromJson(r.getReader(),
             new TypeToken<List<TaskInfo>>() {}.getType());
@@ -52,7 +51,7 @@
   @Test
   public void listTasksWithoutViewQueueCapability() throws Exception {
     RestResponse r = userSession.get("/config/server/tasks/");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     List<TaskInfo> result =
         newGson().fromJson(r.getReader(),
             new TypeToken<List<TaskInfo>>() {}.getType());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
index 242e1ee..1fbebe8 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
@@ -14,19 +14,15 @@
 
 package com.google.gerrit.acceptance.rest.group;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class AddMemberIT extends AbstractDaemonTest {
   @Test
   public void addNonExistingMember_NotFound() throws Exception {
-    int status =
-        adminSession.put("/groups/Administrators/members/non-existing")
-            .getStatusCode();
-    assertThat(status).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    adminSession
+        .put("/groups/Administrators/members/non-existing")
+        .assertNotFound();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
index 682059c..0362f59 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.server.project.BanCommit;
 import com.google.gerrit.server.project.BanCommit.BanResultInfo;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.junit.Test;
@@ -39,7 +38,7 @@
     RestResponse r =
         adminSession.put("/projects/" + project.get() + "/ban/",
             BanCommit.Input.fromCommits(c.name()));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     BanResultInfo info = newGson().fromJson(r.getReader(), BanResultInfo.class);
     assertThat(Iterables.getOnlyElement(info.newlyBanned)).isEqualTo(c.name());
     assertThat(info.alreadyBanned).isNull();
@@ -59,7 +58,7 @@
 
     r = adminSession.put("/projects/" + project.get() + "/ban/",
         BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     BanResultInfo info = newGson().fromJson(r.getReader(), BanResultInfo.class);
     assertThat(Iterables.getOnlyElement(info.alreadyBanned))
       .isEqualTo("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96");
@@ -69,9 +68,9 @@
 
   @Test
   public void banCommit_Forbidden() throws Exception {
-    RestResponse r =
-        userSession.put("/projects/" + project.get() + "/ban/",
-            BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userSession
+        .put("/projects/" + project.get() + "/ban/", BanCommit.Input.fromCommits(
+            "a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"))
+        .assertForbidden();
   }
 }
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 62dd729..46f93b6 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
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.block;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -29,7 +28,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.git.ProjectConfig;
 
 import org.eclipse.jgit.lib.Constants;
 import org.junit.Before;
@@ -84,9 +82,7 @@
   }
 
   private void blockCreateReference() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    block(cfg, Permission.CREATE, ANONYMOUS_USERS, "refs/*");
-    saveProjectConfig(project, cfg);
+    block(Permission.CREATE, ANONYMOUS_USERS, "refs/*");
   }
 
   private void grantOwner() throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 030897b..07a2110 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -39,7 +39,6 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectState;
 
-import org.apache.http.HttpStatus;
 import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Constants;
@@ -58,7 +57,7 @@
   public void testCreateProjectHttp() throws Exception {
     String newProjectName = name("newProject");
     RestResponse r = adminSession.put("/projects/" + newProjectName);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CREATED);
+    r.assertCreated();
     ProjectInfo p = newGson().fromJson(r.getReader(), ProjectInfo.class);
     assertThat(p.name).isEqualTo(newProjectName);
     ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
@@ -70,32 +69,36 @@
   @Test
   public void testCreateProjectHttpWhenProjectAlreadyExists_Conflict()
       throws Exception {
-    RestResponse r = adminSession.put("/projects/" + allProjects.get());
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    adminSession
+        .put("/projects/" + allProjects.get())
+        .assertConflict();
   }
 
   @Test
   public void testCreateProjectHttpWhenProjectAlreadyExists_PreconditionFailed()
       throws Exception {
-    RestResponse r = adminSession.putWithHeader("/projects/" + allProjects.get(),
-        new BasicHeader(HttpHeaders.IF_NONE_MATCH, "*"));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_PRECONDITION_FAILED);
+    adminSession
+        .putWithHeader("/projects/" + allProjects.get(),
+            new BasicHeader(HttpHeaders.IF_NONE_MATCH, "*"))
+        .assertPreconditionFailed();
   }
 
   @Test
   @UseLocalDisk
   public void testCreateProjectHttpWithUnreasonableName_BadRequest()
       throws Exception {
-    RestResponse r = adminSession.put("/projects/" + Url.encode(name("invalid/../name")));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminSession
+        .put("/projects/" + Url.encode(name("invalid/../name")))
+        .assertBadRequest();
   }
 
   @Test
   public void testCreateProjectHttpWithNameMismatch_BadRequest() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name("otherName");
-    RestResponse r = adminSession.put("/projects/" + name("someName"), in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminSession
+        .put("/projects/" + name("someName"), in)
+        .assertBadRequest();
   }
 
   @Test
@@ -103,8 +106,9 @@
       throws Exception {
     ProjectInput in = new ProjectInput();
     in.branches = Collections.singletonList(name("invalid ref name"));
-    RestResponse r = adminSession.put("/projects/" + name("newProject"), in);
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    adminSession
+        .put("/projects/" + name("newProject"), in)
+        .assertBadRequest();
   }
 
   @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 c9347cd..b02357e 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
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.block;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -26,7 +25,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.git.ProjectConfig;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -76,9 +74,7 @@
   }
 
   private void blockForcePush() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    block(cfg, Permission.PUSH, ANONYMOUS_USERS, "refs/heads/*").setForce(true);
-    saveProjectConfig(project, cfg);
+    block(Permission.PUSH, ANONYMOUS_USERS, "refs/heads/*").setForce(true);
   }
 
   private void grantOwner() throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
new file mode 100644
index 0000000..66d04df
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
@@ -0,0 +1,59 @@
+// 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.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Branch;
+
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class FileBranchIT extends AbstractDaemonTest {
+
+  private Branch.NameKey branch;
+
+  @Before
+  public void setUp() throws Exception {
+    branch = new Branch.NameKey(project, "master");
+    PushOneCommit.Result change = createChange();
+    approve(change.getChangeId());
+    revision(change).submit();
+  }
+
+  @Test
+  public void getFileContent() throws Exception {
+    BinaryResult content = branch().file(PushOneCommit.FILE_NAME);
+    assertThat(content.asString()).isEqualTo(PushOneCommit.FILE_CONTENT);
+  }
+
+  @Test(expected = ResourceNotFoundException.class)
+  public void getNonExistingFile() throws Exception {
+    branch().file("does-not-exist");
+  }
+
+  private BranchApi branch() throws Exception {
+    return gApi.projects()
+        .name(branch.getParentKey().get())
+        .branch(branch.get());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
index d680541..f55f591 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GcAssert;
 import com.google.gerrit.acceptance.RestResponse;
@@ -23,7 +21,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 
-import org.apache.http.HttpStatus;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -43,29 +40,27 @@
 
   @Test
   public void testGcNonExistingProject_NotFound() throws Exception {
-    assertThat(POST("/projects/non-existing/gc")).isEqualTo(
-        HttpStatus.SC_NOT_FOUND);
+    POST("/projects/non-existing/gc").assertNotFound();
   }
 
   @Test
   public void testGcNotAllowed_Forbidden() throws Exception {
-    assertThat(
-        userSession.post("/projects/" + allProjects.get() + "/gc")
-            .getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    userSession
+        .post("/projects/" + allProjects.get() + "/gc")
+        .assertForbidden();
   }
 
   @Test
   @UseLocalDisk
   public void testGcOneProject() throws Exception {
-    assertThat(POST("/projects/" + allProjects.get() + "/gc")).isEqualTo(
-        HttpStatus.SC_OK);
+    POST("/projects/" + allProjects.get() + "/gc").assertOK();
     gcAssert.assertHasPackFile(allProjects);
     gcAssert.assertHasNoPackFile(project, project2);
   }
 
-  private int POST(String endPoint) throws IOException {
+  private RestResponse POST(String endPoint) throws IOException {
     RestResponse r = adminSession.post(endPoint);
     r.consume();
-    return r.getStatusCode();
+    return r;
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
index ea3a5db..f87b921 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -71,10 +70,8 @@
 
   private void assertChildNotFound(Project.NameKey parent, String child)
       throws Exception {
-    try {
-      gApi.projects().name(parent.get()).child(child);
-    } catch (ResourceNotFoundException e) {
-      assertThat(e.getMessage()).contains(child);
-    }
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage(child);
+    gApi.projects().name(parent.get()).child(child).get();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
index 7044ad0..d32be8b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.server.git.ProjectConfig;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -40,7 +39,7 @@
   @Before
   public void setUp() throws Exception {
     repo = GitUtil.newTestRepository(repoManager.openRepository(project));
-    blockRead(project, "refs/*");
+    blockRead("refs/*");
   }
 
   @After
@@ -130,15 +129,15 @@
   }
 
   private void assertNotFound(ObjectId id) throws Exception {
-    RestResponse r = userSession.get(
-        "/projects/" + project.get() + "/commits/" + id.name());
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+    userSession
+        .get("/projects/" + project.get() + "/commits/" + id.name())
+        .assertNotFound();
   }
 
   private CommitInfo getCommit(ObjectId id) throws Exception {
     RestResponse r = userSession.get(
         "/projects/" + project.get() + "/commits/" + id.name());
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     CommitInfo result = newGson().fromJson(r.getReader(), CommitInfo.class);
     r.consume();
     return result;
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index 0799d48..a3a107d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -37,7 +37,7 @@
 
   @Test
   public void listBranchesOfNonVisibleProject_NotFound() throws Exception {
-    blockRead(project, "refs/*");
+    blockRead("refs/*");
     setApiUser(user);
     exception.expect(ResourceNotFoundException.class);
     gApi.projects().name(project.get()).branches().get();
@@ -66,7 +66,7 @@
 
   @Test
   public void listBranchesSomeHidden() throws Exception {
-    blockRead(project, "refs/heads/dev");
+    blockRead("refs/heads/dev");
     String master = pushTo("refs/heads/master").getCommit().name();
     pushTo("refs/heads/dev");
     setApiUser(user);
@@ -79,7 +79,7 @@
 
   @Test
   public void listBranchesHeadHidden() throws Exception {
-    blockRead(project, "refs/heads/master");
+    blockRead("refs/heads/master");
     pushTo("refs/heads/master");
     String dev = pushTo("refs/heads/dev").getCommit().name();
     setApiUser(user);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index 80ad493..78e0ba2 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -29,11 +28,9 @@
 
   @Test
   public void listChildrenOfNonExistingProject_NotFound() throws Exception {
-    try {
-      gApi.projects().name(name("non-existing")).child("children");
-    } catch (ResourceNotFoundException e) {
-      assertThat(e.getMessage()).contains("non-existing");
-    }
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("non-existing");
+    gApi.projects().name(name("non-existing")).child("children");
   }
 
   @Test
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 1e51571..e86bb29 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
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
 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;
@@ -197,9 +198,10 @@
         .containsExactly(allProjects, allUsers, project).inOrder();
   }
 
-  private static void assertBadRequest(ListRequest req) throws Exception {
+  private void assertBadRequest(ListRequest req) throws Exception {
     try {
       req.get();
+      fail("Expected BadRequestException");
     } catch (BadRequestException expected) {
       // Expected.
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java
index 67d4d1f..e09c63a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.SetParent;
 
-import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 public class SetParentIT extends AbstractDaemonTest {
@@ -31,7 +30,7 @@
     RestResponse r =
         userSession.put("/projects/" + project.get() + "/parent",
             newParentInput(parent));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_FORBIDDEN);
+    r.assertForbidden();
     r.consume();
   }
 
@@ -41,11 +40,11 @@
     RestResponse r =
         adminSession.put("/projects/" + project.get() + "/parent",
             newParentInput(parent));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     r.consume();
 
     r = adminSession.get("/projects/" + project.get() + "/parent");
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.assertOK();
     String newParent =
         newGson().fromJson(r.getReader(), String.class);
     assertThat(newParent).isEqualTo(parent);
@@ -57,7 +56,7 @@
     RestResponse r =
         adminSession.put("/projects/" + allProjects.get() + "/parent",
             newParentInput(project.get()));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    r.assertConflict();
     r.consume();
   }
 
@@ -66,19 +65,19 @@
     RestResponse r =
         adminSession.put("/projects/" + project.get() + "/parent",
             newParentInput(project.get()));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    r.assertConflict();
     r.consume();
 
     Project.NameKey child = createProject("child", project, true);
     r = adminSession.put("/projects/" + project.get() + "/parent",
            newParentInput(child.get()));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    r.assertConflict();
     r.consume();
 
     String grandchild = createProject("grandchild", child, true).get();
     r = adminSession.put("/projects/" + project.get() + "/parent",
            newParentInput(grandchild));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_CONFLICT);
+    r.assertConflict();
     r.consume();
   }
 
@@ -87,7 +86,7 @@
     RestResponse r =
         adminSession.put("/projects/" + project.get() + "/parent",
             newParentInput("non-existing"));
-    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_UNPROCESSABLE_ENTITY);
+    r.assertUnprocessableEntity();
     r.consume();
   }
 
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 b22d26b9..33cfe99 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
@@ -26,7 +26,6 @@
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 
-import org.apache.http.HttpStatus;
 import org.eclipse.jgit.api.PushCommand;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.transport.PushResult;
@@ -42,8 +41,9 @@
 
   @Test
   public void listTagsOfNonExistingProject() throws Exception {
-    assertThat(adminSession.get("/projects/non-existing/tags").getStatusCode())
-        .isEqualTo(HttpStatus.SC_NOT_FOUND);
+    adminSession
+        .get("/projects/non-existing/tags")
+        .assertNotFound();
   }
 
   @Test
@@ -60,15 +60,15 @@
 
   @Test
   public void listTagsOfNonVisibleProject() throws Exception {
-    blockRead(project, "refs/*");
-    assertThat(
-        userSession.get("/projects/" + project.get() + "/tags").getStatusCode())
-        .isEqualTo(HttpStatus.SC_NOT_FOUND);
+    blockRead("refs/*");
+    userSession
+        .get("/projects/" + project.get() + "/tags")
+        .assertNotFound();
   }
 
   @Test
   public void listTagsOfNonVisibleProjectWithApi() throws Exception {
-    blockRead(project, "refs/*");
+    blockRead("refs/*");
     setApiUser(user);
     exception.expect(ResourceNotFoundException.class);
     gApi.projects().name(project.get()).tags().get();
@@ -76,7 +76,7 @@
 
   @Test
   public void getTagOfNonVisibleProjectWithApi() throws Exception {
-    blockRead(project, "refs/*");
+    blockRead("refs/*");
     exception.expect(ResourceNotFoundException.class);
     gApi.projects().name(project.get()).tag("tag").get();
   }
@@ -211,7 +211,7 @@
     assertThat(result.get(1).ref).isEqualTo("refs/tags/" + tag2.name);
     assertThat(result.get(1).revision).isEqualTo(r2.getCommitId().getName());
 
-    blockRead(project, "refs/heads/hidden");
+    blockRead("refs/heads/hidden");
     result = getTags().get();
     assertThat(result).hasSize(1);
     assertThat(result.get(0).ref).isEqualTo("refs/tags/" + tag1.name);
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 b784f05..ae4003c 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
@@ -289,6 +289,20 @@
   }
 
   @Test
+  public void listChangeWithDrafts() throws Exception {
+    for (Integer line : lines) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      DraftInput comment = newDraft(
+          "file1", Side.REVISION, line, "comment 1");
+      addDraft(changeId, revId, comment);
+      assertThat(gApi.changes().query(
+          "change:" + changeId + " has:draft").get()).hasSize(1);
+    }
+  }
+
+  @Test
   public void publishCommentsAllRevisions() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 357f268..387092d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -18,6 +18,7 @@
 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;
+import static com.google.gerrit.testutil.GerritServerTests.isNoteDbTestEnabled;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -127,7 +128,7 @@
     revision(r).review(new ReviewInput().label(P.getName(), 0));
     ChangeInfo c = get(r.getChangeId());
     LabelInfo q = c.labels.get(P.getName());
-    assertThat(q.all).hasSize(2);
+    assertThat(q.all).hasSize(isNoteDbTestEnabled() ? 1 : 2);
     assertThat(q.disliked).isNull();
     assertThat(q.rejected).isNull();
     assertThat(q.blocking).isNull();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/LabelTypeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/LabelTypeIT.java
index 363a7e4..f7e11fb 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/LabelTypeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/LabelTypeIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.Util;
@@ -46,6 +47,15 @@
   }
 
   @Test
+  public void failChangedLabelValueOnClosedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    merge(r);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is closed");
+    revision(r).review(ReviewInput.reject());
+  }
+
+  @Test
   public void noCopyMinScoreOnRework() throws Exception {
     codeReview.setCopyMinScore(false);
     saveLabelConfig();
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 28e0d24..b6cd45f 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
@@ -49,6 +49,7 @@
   public static final String ADMIN_CREATE_PROJECT = "/admin/create-project/";
   public static final String ADMIN_PLUGINS = "/admin/plugins/";
   public static final String MY_GROUPS = "/groups/self";
+  public static final String DOCUMENTATION = "/Documentation/";
 
   public static String toChange(final ChangeInfo c) {
     return toChange(c.getId());
@@ -140,6 +141,10 @@
     return SETTINGS_EXTENSION + pluginName + "/" + path;
   }
 
+  public static String toDocumentationQuery(String query) {
+    return DOCUMENTATION + KeyUtil.encode(query);
+  }
+
   private static String status(Status status) {
     switch (status) {
       case ABANDONED:
diff --git a/gerrit-extension-api/pom.xml b/gerrit-extension-api/pom.xml
index e879347..07f2965 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.12</version>
+  <version>2.13-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/GerritApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java
index 8b9f520..4404f94 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java
@@ -22,11 +22,11 @@
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 
 public interface GerritApi {
-  public Accounts accounts();
-  public Changes changes();
-  public Config config();
-  public Groups groups();
-  public Projects projects();
+  Accounts accounts();
+  Changes changes();
+  Config config();
+  Groups groups();
+  Projects projects();
 
   /**
    * A default implementation which allows source compatibility
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java
index 32f8488..9cddda9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java
@@ -38,6 +38,11 @@
   AccountApi id(String id) throws RestApiException;
 
   /**
+   * @see #id(String)
+   */
+  AccountApi id(int id) throws RestApiException;
+
+  /**
    * Look up the account of the current in-scope user.
    *
    * @see #id(String)
@@ -118,6 +123,11 @@
     }
 
     @Override
+    public AccountApi id(int id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public AccountApi self() throws RestApiException {
       throw new NotImplementedException();
     }
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 ce07098..d927231 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
@@ -58,6 +58,19 @@
    */
   RevisionApi revision(String id) throws RestApiException;
 
+  /**
+   * Look up the reviewer of the change.
+   * <p>
+   * @param id ID of the account, can be a string of the format
+   *     "Full Name <mail@example.com>", just the email address, a full name
+   *     if it is unique, an account ID, a user name or 'self' for the
+   *     calling user.
+   * @return API for accessing the reviewer.
+   * @throws RestApiException if id is not account ID or is a user that isn't
+   *     known to be a reviewer for this change.
+   */
+  ReviewerApi reviewer(String id) throws RestApiException;
+
   void abandon() throws RestApiException;
   void abandon(AbandonInput in) throws RestApiException;
 
@@ -177,6 +190,11 @@
     }
 
     @Override
+    public ReviewerApi reviewer(String id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public RevisionApi revision(String id) throws RestApiException {
       throw new NotImplementedException();
     }
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
new file mode 100644
index 0000000..11b670d
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
@@ -0,0 +1,25 @@
+// 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.extensions.api.changes;
+
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+import java.util.Map;
+
+public interface ReviewerApi {
+
+  Map<String, Short> votes() throws RestApiException;
+  void deleteVote(String label) throws RestApiException;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java
index 91cb70e..e7c98e9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
@@ -25,6 +26,11 @@
   void delete() throws RestApiException;
 
   /**
+   * Returns the content of a file from the HEAD revision.
+   */
+  BinaryResult file(String path) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
    **/
@@ -43,5 +49,10 @@
     public void delete() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public BinaryResult file(String path) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java
new file mode 100644
index 0000000..3fa7bb2
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java
@@ -0,0 +1,42 @@
+// 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.extensions.auth.oauth;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+import java.io.IOException;
+
+@ExtensionPoint
+public interface OAuthLoginProvider {
+
+  /**
+   * Performs a login with an OAuth2 provider for Git over HTTP
+   * communication.
+   *
+   * An implementation of this interface must transmit the given
+   * user name and secret, which can be either an OAuth2 access token
+   * or a password, to the OAuth2 backend for verification.
+   *
+   * @param username the user's identifier.
+   * @param secret the secret to verify, e.g. a previously received
+   * access token or a password.
+   *
+   * @return information about the logged in user, at least
+   * external id, user name and email address.
+   *
+   * @throws IOException if the login failed.
+   */
+  OAuthUserInfo login(String username, String secret) throws IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ReviewerState.java
similarity index 63%
rename from gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java
rename to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ReviewerState.java
index b829a69..a58c959 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ReviewerState.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// 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.
@@ -12,28 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.notedb;
+package com.google.gerrit.extensions.client;
 
-import org.eclipse.jgit.revwalk.FooterKey;
-
-/** State of a reviewer on a change. */
 public enum ReviewerState {
   /** The user has contributed at least one nonzero vote on the change. */
-  REVIEWER(new FooterKey("Reviewer")),
+  REVIEWER,
 
   /** The reviewer was added to the change, but has not voted. */
-  CC(new FooterKey("CC")),
+  CC,
 
   /** The user was previously a reviewer on the change, but was removed. */
-  REMOVED(new FooterKey("Removed"));
-
-  private final FooterKey footerKey;
-
-  private ReviewerState(FooterKey footerKey) {
-    this.footerKey = footerKey;
-  }
-
-  FooterKey getFooterKey() {
-    return footerKey;
-  }
+  REMOVED;
 }
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 cdfe0c6..455243a 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ReviewerState;
 
 import java.sql.Timestamp;
 import java.util.Collection;
@@ -48,6 +49,7 @@
   public Map<String, LabelInfo> labels;
   public Map<String, Collection<String>> permittedLabels;
   public Collection<AccountInfo> removableReviewers;
+  public Map<ReviewerState, Collection<AccountInfo>> reviewers;
   public Collection<ChangeMessageInfo> messages;
 
   public String currentRevision;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FileInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FileInfo.java
index 00d0c18..a812908 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -21,4 +21,5 @@
   public Integer linesInserted;
   public Integer linesDeleted;
   public long sizeDelta;
+  public long size;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java
index 93da347..b3ed37b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java
@@ -21,9 +21,9 @@
 /** Listener interested in server startup and shutdown events. */
 @ExtensionPoint
 public interface LifecycleListener extends EventListener {
-  /** Invoke when the server is starting. */
-  public void start();
+  /** Invoked when the server is starting. */
+  void start();
 
   /** Invoked when the server is stopping. */
-  public void stop();
+  void stop();
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
index 365d056..a6b8d38 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
@@ -35,10 +35,10 @@
   }
 
   public interface MetaData {
-    public String getName();
-    public String getUnitName();
-    public String getUnitSymbol();
-    public String getDescription();
+    String getName();
+    String getUnitName();
+    String getUnitSymbol();
+    String getDescription();
   }
 
   void onUsageDataPublished(Event event);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/RegistrationHandle.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/RegistrationHandle.java
index 2243786..4688f7c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/RegistrationHandle.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/RegistrationHandle.java
@@ -17,5 +17,5 @@
 /** Handle for registered information. */
 public interface RegistrationHandle {
   /** Delete this registration. */
-  public void remove();
+  void remove();
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java
index 7284296..ef38303 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/ReloadableRegistrationHandle.java
@@ -18,7 +18,7 @@
 import com.google.inject.Provider;
 
 public interface ReloadableRegistrationHandle<T> extends RegistrationHandle {
-  public Key<T> getKey();
+  Key<T> getKey();
 
-  public RegistrationHandle replace(Key<T> key, Provider<T> item);
+  RegistrationHandle replace(Key<T> key, Provider<T> item);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java
index f95161d..3b32829 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java
@@ -18,5 +18,5 @@
  * A view which may change, although the underlying resource did not change
  */
 public interface ETagView<R extends RestResource> extends RestReadView<R> {
-  public String getETag(R rsrc);
+  String getETag(R rsrc);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java
index 8032531..29c824f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java
@@ -30,11 +30,11 @@
      * @return time for the Last-Modified header. HTTP truncates the header
      *         value to seconds.
      */
-    public Timestamp getLastModified();
+    Timestamp getLastModified();
   }
 
   /** A resource with an ETag. */
   public interface HasETag {
-    public String getETag();
+    String getETag();
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java
index 63172ce..fb81e0c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java
@@ -29,7 +29,7 @@
    *         assumed unavailable and not presented. This is usually the same as
    *         {@code setVisible(false)}.
    */
-  public Description getDescription(R resource);
+  Description getDescription(R resource);
 
   /** Describes an action invokable through the web interface. */
   public static class Description {
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java
index 05f41d4..4b67260 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandFilter.java
@@ -15,5 +15,5 @@
 package com.google.gwtexpui.globalkey.client;
 
 public interface KeyCommandFilter {
-  public boolean include(KeyCommand key);
+  boolean include(KeyCommand key);
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java
index f7bc907..487e613 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java
@@ -22,7 +22,7 @@
    * @return regular expression to match substrings with; should be treated as
    *     immutable.
    */
-  public RegExp pattern();
+  RegExp pattern();
 
   /**
    * Find and replace a single instance of this pattern in an input.
@@ -36,5 +36,5 @@
    * @return result of regular expression replacement.
    * @throws IllegalArgumentException if the input could not be safely sanitized.
    */
-  public String replace(String input);
+  String replace(String input);
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java
index 950400a..d94f243 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java
@@ -129,9 +129,8 @@
   }
 
   public static boolean hasCacheHeader(HttpServletResponse res) {
-    return res.getHeader("Cache-Control") != null
-        || res.getHeader("Expires") != null
-        || "no-cache".equals(res.getHeader("Pragma"));
+    return res.containsHeader("Cache-Control")
+        || res.containsHeader("Expires");
   }
 
   private static void cache(HttpServletResponse res,
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 a5a02cd..eca28a0 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
@@ -19,92 +19,92 @@
 
 public interface Resources extends ClientBundle {
   @Source("addFileComment.png")
-  public ImageResource addFileComment();
+  ImageResource addFileComment();
 
   @Source("arrowDown.png")
-  public ImageResource arrowDown();
+  ImageResource arrowDown();
 
   @Source("arrowRight.png")
-  public ImageResource arrowRight();
+  ImageResource arrowRight();
 
   @Source("arrowUp.png")
-  public ImageResource arrowUp();
+  ImageResource arrowUp();
 
   @Source("deleteHover.png")
-  public ImageResource deleteHover();
+  ImageResource deleteHover();
 
   @Source("deleteNormal.png")
-  public ImageResource deleteNormal();
+  ImageResource deleteNormal();
 
   @Source("diffy26.png")
-  public ImageResource gerritAvatar26();
+  ImageResource gerritAvatar26();
 
   @Source("downloadIcon.png")
-  public ImageResource downloadIcon();
+  ImageResource downloadIcon();
 
   @Source("draftComments.png")
-  public ImageResource draftComments();
+  ImageResource draftComments();
 
   @Source("editText.png")
-  public ImageResource edit();
+  ImageResource edit();
 
   @Source("editUndo.png")
-  public ImageResource editUndo();
+  ImageResource editUndo();
 
   @Source("gear.png")
-  public ImageResource gear();
+  ImageResource gear();
 
   @Source("goNext.png")
-  public ImageResource goNext();
+  ImageResource goNext();
 
   @Source("goPrev.png")
-  public ImageResource goPrev();
+  ImageResource goPrev();
 
   @Source("goUp.png")
-  public ImageResource goUp();
+  ImageResource goUp();
 
   @Source("greenCheck.png")
-  public ImageResource greenCheck();
+  ImageResource greenCheck();
 
   @Source("info.png")
-  public ImageResource info();
+  ImageResource info();
 
   @Source("listAdd.png")
-  public ImageResource listAdd();
+  ImageResource listAdd();
 
   @Source("mediaFloppy.png")
-  public ImageResource save();
+  ImageResource save();
 
   @Source("merge.png")
-  public ImageResource merge();
+  ImageResource merge();
 
   @Source("queryIcon.png")
-  public ImageResource queryIcon();
+  ImageResource queryIcon();
 
   @Source("readOnly.png")
-  public ImageResource readOnly();
+  ImageResource readOnly();
 
   @Source("redNot.png")
-  public ImageResource redNot();
+  ImageResource redNot();
 
   @Source("sideBySideDiff.png")
-  public ImageResource sideBySideDiff();
+  ImageResource sideBySideDiff();
 
   @Source("starFilled.png")
-  public ImageResource starFilled();
+  ImageResource starFilled();
 
   @Source("starOpen.png")
-  public ImageResource starOpen();
+  ImageResource starOpen();
 
   @Source("undoNormal.png")
-  public ImageResource undoNormal();
+  ImageResource undoNormal();
 
   @Source("unifiedDiff.png")
-  public ImageResource unifiedDiff();
+  ImageResource unifiedDiff();
 
   @Source("warning.png")
-  public ImageResource warning();
+  ImageResource warning();
 
   @Source("question.png")
-  public ImageResource question();
+  ImageResource question();
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountPreferencesInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountPreferencesInfo.java
index 11a1b6a..afde038 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountPreferencesInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountPreferencesInfo.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DateFormat;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DiffView;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DownloadCommand;
+import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.EmailStrategy;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.ReviewCategoryStrategy;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.TimeFormat;
 import com.google.gwt.core.client.JavaScriptObject;
@@ -44,7 +45,6 @@
     p.useFlashClipboard(defaultPrefs.isUseFlashClipboard());
     p.downloadScheme(defaultPrefs.getDownloadUrl());
     p.downloadCommand(defaultPrefs.getDownloadCommand());
-    p.copySelfOnEmail(defaultPrefs.isCopySelfOnEmails());
     p.dateFormat(defaultPrefs.getDateFormat());
     p.timeFormat(defaultPrefs.getTimeFormat());
     p.relativeDateInChangeTable(defaultPrefs.isRelativeDateInChangeTable());
@@ -53,6 +53,7 @@
     p.muteCommonPathPrefixes(defaultPrefs.isMuteCommonPathPrefixes());
     p.reviewCategoryStrategy(defaultPrefs.getReviewCategoryStrategy());
     p.diffView(defaultPrefs.getDiffView());
+    p.emailStrategy(defaultPrefs.getEmailStrategy());
     return p;
   }
 
@@ -82,9 +83,6 @@
   private final native String downloadCommandRaw()
   /*-{ return this.download_command }-*/;
 
-  public final native boolean copySelfOnEmail()
-  /*-{ return this.copy_self_on_email || false }-*/;
-
   public final DateFormat dateFormat() {
     String s = dateFormatRaw();
     return s != null ? DateFormat.valueOf(s) : null;
@@ -125,6 +123,14 @@
   private final native String diffViewRaw()
   /*-{ return this.diff_view }-*/;
 
+  public final EmailStrategy emailStrategy() {
+    String s = emailStrategyRaw();
+    return s != null ? EmailStrategy.valueOf(s) : null;
+  }
+
+  private final native String emailStrategyRaw()
+  /*-{ return this.email_strategy }-*/;
+
   public final native JsArray<TopMenuItem> my()
   /*-{ return this.my; }-*/;
 
@@ -146,9 +152,6 @@
   public final native void downloadCommandRaw(String d)
   /*-{ this.download_command = d }-*/;
 
-  public final native void copySelfOnEmail(boolean c)
-  /*-{ this.copy_self_on_email = c }-*/;
-
   public final void dateFormat(DateFormat f) {
     dateFormatRaw(f != null ? f.toString() : null);
   }
@@ -185,6 +188,12 @@
   private final native void diffViewRaw(String d)
   /*-{ this.diff_view = d }-*/;
 
+  public final void emailStrategy(EmailStrategy s) {
+    emailStrategyRaw(s != null ? s.toString() : null);
+  }
+  private final native void emailStrategyRaw(String s)
+  /*-{ this.email_strategy = s }-*/;
+
   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/ChangeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
index cace7ad..c5639c1 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
@@ -30,6 +30,7 @@
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.SortedSet;
@@ -84,6 +85,16 @@
     return allLabels().keySet();
   }
 
+  public final Set<Integer> removableReviewerIds() {
+    Set<Integer> removable = new HashSet<>();
+    if (removableReviewers() != null) {
+      for (AccountInfo a : Natives.asList(removableReviewers())) {
+        removable.add(a._accountId());
+      }
+    }
+    return removable;
+  }
+
   public final native String id() /*-{ return this.id; }-*/;
   public final native String project() /*-{ return this.project; }-*/;
   public final native String branch() /*-{ return this.branch; }-*/;
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 d95f9ef..555e06e 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
@@ -34,6 +34,11 @@
   // 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
+  public final long size() {
+    return (long)_size();
+  }
+  private final native double _size() /*-{ return this.size || 0; }-*/;
+
   public final long sizeDelta() {
     return (long)_sizeDelta();
   }
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 55ef892..750412d 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
@@ -36,6 +36,7 @@
 
   public final native String allProjects() /*-{ return this.all_projects; }-*/;
   public final native String allUsers() /*-{ return this.all_users; }-*/;
+  public final native boolean docSearch() /*-{ return this.doc_search; }-*/;
   public final native String docUrl() /*-{ return this.doc_url; }-*/;
   public final native boolean editGpgKeys() /*-{ return this.edit_gpg_keys || false; }-*/;
   public final native String reportBugUrl() /*-{ return this.report_bug_url; }-*/;
diff --git a/gerrit-gwtui/gwt.defs b/gerrit-gwtui/gwt.defs
index 783c343..88d603a 100644
--- a/gerrit-gwtui/gwt.defs
+++ b/gerrit-gwtui/gwt.defs
@@ -70,7 +70,7 @@
     strict = True,
     experimental_args = args,
     vm_args = GWT_JVM_ARGS,
-    visibility = ['//:eclipse'],
+    visibility = ['PUBLIC'],
   )
 
   gwt_binary(
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 c77b71f..1c44ebc 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client;
 
+import com.google.gerrit.client.change.Resources;
 import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.client.info.AccountPreferencesInfo;
 import com.google.gerrit.reviewdb.client.Account;
@@ -134,4 +135,19 @@
         + NumberFormat.getFormat("#.0").format(bytes / Math.pow(1024, exp))
         + " " + "KMGTPE".charAt(exp - 1) + "iB";
   }
+
+  public static String formatPercentage(long size, long delta) {
+    if (size == 0) {
+      return Resources.C.notAvailable();
+    }
+    return (delta > 0 ? "+" : "-") + formatAbsPercentage(size, delta);
+  }
+
+  public static String formatAbsPercentage(long size, long delta) {
+    if (size == 0) {
+      return Resources.C.notAvailable();
+    }
+    int p = Math.abs(Math.round(delta * 100 / size));
+    return p + "%";
+  }
 }
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 560fa9e..139b9c0 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
@@ -25,6 +25,7 @@
 import com.google.gerrit.client.admin.ProjectScreen;
 import com.google.gerrit.client.api.ApiGlue;
 import com.google.gerrit.client.api.PluginLoader;
+import com.google.gerrit.client.change.LocalComments;
 import com.google.gerrit.client.changes.ChangeConstants;
 import com.google.gerrit.client.changes.ChangeListScreen;
 import com.google.gerrit.client.config.ConfigServerApi;
@@ -117,6 +118,7 @@
   private static AccountPreferencesInfo myPrefs;
   private static UrlAliasMatcher urlAliasMatcher;
   private static boolean hasDocumentation;
+  private static boolean docSearch;
   private static String docUrl;
   private static HostPageData.Theme myTheme;
   private static String defaultScreenToken;
@@ -205,6 +207,9 @@
       doSignIn(token);
     } else {
       view.setToken(token);
+      if (isSignedIn()) {
+        LocalComments.saveInlineComments();
+      }
       body.setView(view);
     }
   }
@@ -483,6 +488,7 @@
           hasDocumentation = true;
           docUrl = du;
         }
+        docSearch = info.gerrit().docSearch();
       }
     }));
     HostPageDataService hpd = GWT.create(HostPageDataService.class);
@@ -914,6 +920,10 @@
     urlAliasMatcher.updateUserAliases(myPrefs.urlAliases());
   }
 
+  public static boolean hasDocSearch() {
+    return docSearch;
+  }
+
   private static void getDocIndex(final AsyncCallback<DocInfo> cb) {
     RequestBuilder req =
         new RequestBuilder(RequestBuilder.HEAD, GWT.getHostPageBaseURL()
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 269999c..6802a0d 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
@@ -126,4 +126,7 @@
   String stringListPanelDelete();
   String stringListPanelUp();
   String stringListPanelDown();
+
+  String searchDropdownChanges();
+  String searchDropdownDoc();
 }
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 fb74506..83736cd 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
@@ -109,3 +109,6 @@
 stringListPanelDelete = Delete
 stringListPanelUp = Up
 stringListPanelDown = Down
+
+searchDropdownChanges = Changes
+searchDropdownDoc = Docs
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java
index 21da8ce..58442b8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java
@@ -36,4 +36,6 @@
   String cannotDownloadPlugin(String scriptPath);
 
   String parentUpdateFailed(String message);
+
+  String fileCount(int fileNumber, int fileCount);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.properties
index 2832d41..b2d67b8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.properties
@@ -17,3 +17,5 @@
 cannotDownloadPlugin = Cannot load plugin from {0}
 
 parentUpdateFailed = Setting parent project failed: {0}
+
+fileCount = File {0} of {1}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java
index 45b1d52..83a187b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java
@@ -27,6 +27,7 @@
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.ListBox;
 import com.google.gwt.user.client.ui.SuggestBox;
 import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
@@ -34,6 +35,7 @@
 
 class SearchPanel extends Composite {
   private final HintTextBox searchBox;
+  private final ListBox dropdown;
   private HandlerRegistration regFocus;
 
   SearchPanel() {
@@ -54,6 +56,18 @@
       }
     });
 
+    if (Gerrit.hasDocSearch()) {
+      dropdown = new ListBox();
+      dropdown.setStyleName("searchDropdown");
+      dropdown.addItem(Gerrit.C.searchDropdownChanges());
+      dropdown.addItem(Gerrit.C.searchDropdownDoc());
+      dropdown.setVisibleItemCount(1);
+      dropdown.setSelectedIndex(0);
+    } else {
+      // Doc search is NOT available.
+      dropdown = null;
+    }
+
     final SuggestBox suggestBox =
         new SuggestBox(new SearchSuggestOracle(), searchBox, suggestionDisplay);
     searchBox.setStyleName("searchTextBox");
@@ -70,6 +84,9 @@
     });
 
     body.add(suggestBox);
+    if (dropdown != null) {
+      body.add(dropdown);
+    }
     body.add(searchButton);
   }
 
@@ -110,14 +127,23 @@
 
     searchBox.setFocus(false);
 
-    if (query.matches("^[1-9][0-9]*$")) {
-      Gerrit.display(PageLinks.toChange(Change.Id.parse(query)));
+    if (dropdown != null
+        && dropdown.getSelectedValue().equals(Gerrit.C.searchDropdownDoc())) {
+      // doc
+      Gerrit.display(PageLinks.toDocumentationQuery(query));
     } else {
-      Gerrit.display(PageLinks.toChangeQuery(query), QueryScreen.forQuery(query));
+      // changes
+      if (query.matches("^[1-9][0-9]*$")) {
+        Gerrit.display(PageLinks.toChange(Change.Id.parse(query)));
+      } else {
+        Gerrit.display(
+            PageLinks.toChangeQuery(query), QueryScreen.forQuery(query));
+      }
     }
   }
 
-  private static class MySuggestionDisplay extends SuggestBox.DefaultSuggestionDisplay {
+  private static class MySuggestionDisplay
+      extends SuggestBox.DefaultSuggestionDisplay {
     private boolean isSuggestionSelected;
 
     private MySuggestionDisplay() {
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 94884fa..e49b95f 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
@@ -31,7 +31,6 @@
   String contextWholeFile();
   String showSiteHeader();
   String useFlashClipboard();
-  String copySelfOnEmails();
   String reviewCategoryLabel();
   String messageShowInReviewCategoryNone();
   String messageShowInReviewCategoryName();
@@ -159,4 +158,9 @@
   String welcomeAgreementText();
   String welcomeAgreementLater();
   String welcomeContinue();
+
+  String messageEnabled();
+  String messageCCMeOnMyComments();
+  String messageDisabled();
+  String emailFieldLabel();
 }
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 0944448..9a00ded 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
@@ -7,7 +7,6 @@
 accountId = Account ID
 showSiteHeader = Show Site Header
 useFlashClipboard = Use Flash Clipboard Widget
-copySelfOnEmails = CC Me On Comments I Write
 reviewCategoryLabel = Display In Review Category
 messageShowInReviewCategoryNone = None (default)
 messageShowInReviewCategoryName = Show Name
@@ -15,6 +14,11 @@
 messageShowInReviewCategoryUsername = Show Username
 messageShowInReviewCategoryAbbrev = Show Abbreviated Name
 
+emailFieldLabel = Email Notifications:
+messageEnabled = Enabled
+messageCCMeOnMyComments = CC Me On Comments I Write
+messageDisabled = Disabled
+
 maximumPageSizeFieldLabel = Maximum Page Size:
 diffViewLabel = Diff View:
 dateFormatLabel = Date/Time Format:
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 2b20ad6..c0b556c 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
@@ -29,6 +29,7 @@
 import com.google.gerrit.client.rpc.ScreenLoadCallback;
 import com.google.gerrit.client.ui.OnEditEnabler;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences;
+import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.EmailStrategy;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.ReviewCategoryStrategy;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -50,7 +51,6 @@
 public class MyPreferencesScreen extends SettingsScreen {
   private CheckBox showSiteHeader;
   private CheckBox useFlashClipboard;
-  private CheckBox copySelfOnEmails;
   private CheckBox relativeDateInChangeTable;
   private CheckBox sizeBarInChangeTable;
   private CheckBox legacycidInChangeTable;
@@ -60,6 +60,7 @@
   private ListBox timeFormat;
   private ListBox reviewCategoryStrategy;
   private ListBox diffView;
+  private ListBox emailStrategy;
   private StringListPanel myMenus;
   private Button save;
 
@@ -69,7 +70,6 @@
 
     showSiteHeader = new CheckBox(Util.C.showSiteHeader());
     useFlashClipboard = new CheckBox(Util.C.useFlashClipboard());
-    copySelfOnEmails = new CheckBox(Util.C.copySelfOnEmails());
     maximumPageSize = new ListBox();
     for (final short v : PAGESIZE_CHOICES) {
       maximumPageSize.addItem(Util.M.rowsPerPage(v), String.valueOf(v));
@@ -92,6 +92,20 @@
         Util.C.messageShowInReviewCategoryAbbrev(),
         AccountGeneralPreferences.ReviewCategoryStrategy.ABBREV.name());
 
+    emailStrategy = new ListBox();
+    emailStrategy.addItem(Util.C.messageEnabled(),
+        AccountGeneralPreferences.EmailStrategy.ENABLED.name());
+    emailStrategy
+        .addItem(
+            Util.C.messageCCMeOnMyComments(),
+            AccountGeneralPreferences.EmailStrategy.CC_ON_OWN_COMMENTS
+                .name());
+    emailStrategy
+        .addItem(
+            Util.C.messageDisabled(),
+            AccountGeneralPreferences.EmailStrategy.DISABLED
+                .name());
+
     diffView = new ListBox();
     diffView.addItem(
         com.google.gerrit.client.changes.Util.C.sideBySide(),
@@ -141,7 +155,7 @@
     muteCommonPathPrefixes = new CheckBox(Util.C.muteCommonPathPrefixes());
 
     boolean flashClippy = !UserAgent.hasJavaScriptClipboard() && UserAgent.Flash.isInstalled();
-    final Grid formGrid = new Grid(10 + (flashClippy ? 1 : 0), 2);
+    final Grid formGrid = new Grid(11 + (flashClippy ? 1 : 0), 2);
 
     int row = 0;
     formGrid.setText(row, labelIdx, "");
@@ -154,10 +168,6 @@
       row++;
     }
 
-    formGrid.setText(row, labelIdx, "");
-    formGrid.setWidget(row, fieldIdx, copySelfOnEmails);
-    row++;
-
     formGrid.setText(row, labelIdx, Util.C.reviewCategoryLabel());
     formGrid.setWidget(row, fieldIdx, reviewCategoryStrategy);
     row++;
@@ -186,6 +196,10 @@
     formGrid.setWidget(row, fieldIdx, muteCommonPathPrefixes);
     row++;
 
+    formGrid.setText(row, labelIdx, Util.C.emailFieldLabel());
+    formGrid.setWidget(row, fieldIdx, emailStrategy);
+    row++;
+
     formGrid.setText(row, labelIdx, Util.C.diffViewLabel());
     formGrid.setWidget(row, fieldIdx, diffView);
 
@@ -208,7 +222,6 @@
     final OnEditEnabler e = new OnEditEnabler(save);
     e.listenTo(showSiteHeader);
     e.listenTo(useFlashClipboard);
-    e.listenTo(copySelfOnEmails);
     e.listenTo(maximumPageSize);
     e.listenTo(dateFormat);
     e.listenTo(timeFormat);
@@ -218,6 +231,7 @@
     e.listenTo(muteCommonPathPrefixes);
     e.listenTo(diffView);
     e.listenTo(reviewCategoryStrategy);
+    e.listenTo(emailStrategy);
   }
 
   @Override
@@ -240,7 +254,6 @@
   private void enable(final boolean on) {
     showSiteHeader.setEnabled(on);
     useFlashClipboard.setEnabled(on);
-    copySelfOnEmails.setEnabled(on);
     maximumPageSize.setEnabled(on);
     dateFormat.setEnabled(on);
     timeFormat.setEnabled(on);
@@ -250,12 +263,12 @@
     muteCommonPathPrefixes.setEnabled(on);
     reviewCategoryStrategy.setEnabled(on);
     diffView.setEnabled(on);
+    emailStrategy.setEnabled(on);
   }
 
   private void display(AccountPreferencesInfo p) {
     showSiteHeader.setValue(p.showSiteHeader());
     useFlashClipboard.setValue(p.useFlashClipboard());
-    copySelfOnEmails.setValue(p.copySelfOnEmail());
     setListBox(maximumPageSize, DEFAULT_PAGESIZE, p.changesPerPage());
     setListBox(dateFormat, AccountGeneralPreferences.DateFormat.STD, //
         p.dateFormat());
@@ -271,6 +284,9 @@
     setListBox(diffView,
         AccountGeneralPreferences.DiffView.SIDE_BY_SIDE,
         p.diffView());
+    setListBox(emailStrategy,
+        AccountGeneralPreferences.EmailStrategy.ENABLED,
+        p.emailStrategy());
     display(p.my());
   }
 
@@ -337,7 +353,6 @@
     AccountPreferencesInfo p = AccountPreferencesInfo.create();
     p.showSiteHeader(showSiteHeader.getValue());
     p.useFlashClipboard(useFlashClipboard.getValue());
-    p.copySelfOnEmail(copySelfOnEmails.getValue());
     p.changesPerPage(getListBox(maximumPageSize, DEFAULT_PAGESIZE));
     p.dateFormat(getListBox(dateFormat,
         AccountGeneralPreferences.DateFormat.STD,
@@ -355,6 +370,8 @@
     p.diffView(getListBox(diffView,
         AccountGeneralPreferences.DiffView.SIDE_BY_SIDE,
         AccountGeneralPreferences.DiffView.values()));
+    p.emailStrategy(getListBox(emailStrategy,
+        EmailStrategy.ENABLED, EmailStrategy.values()));
 
     List<TopMenuItem> items = new ArrayList<>();
     for (List<String> v : myMenus.getValues()) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java
index f1ac27f..63ae5f4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java
@@ -25,11 +25,11 @@
   AdminCss css();
 
   @Source("deleteNormal.png")
-  public ImageResource deleteNormal();
+  ImageResource deleteNormal();
 
   @Source("deleteHover.png")
-  public ImageResource deleteHover();
+  ImageResource deleteHover();
 
   @Source("undoNormal.png")
-  public ImageResource undoNormal();
+  ImageResource undoNormal();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java
index b86d0a8..0bf1b4b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java
@@ -231,19 +231,20 @@
 
           private Set<String> getDiffs(ProjectAccess wantedAccess,
               ProjectAccess newAccess) {
-            final List<AccessSection> wantedSections =
+            List<AccessSection> wantedSections =
                 mergeSections(removeEmptyPermissionsAndSections(wantedAccess.getLocal()));
-            final HashSet<AccessSection> same = new HashSet<>(wantedSections);
-            final HashSet<AccessSection> different =
-                new HashSet<>(wantedSections.size()
-                    + newAccess.getLocal().size());
+            List<AccessSection> newSections =
+                removeEmptyPermissionsAndSections(newAccess.getLocal());
+            HashSet<AccessSection> same = new HashSet<>(wantedSections);
+            HashSet<AccessSection> different =
+                new HashSet<>(wantedSections.size() + newSections.size());
             different.addAll(wantedSections);
-            different.addAll(newAccess.getLocal());
-            same.retainAll(newAccess.getLocal());
+            different.addAll(newSections);
+            same.retainAll(newSections);
             different.removeAll(same);
 
-            final Set<String> differentNames = new HashSet<>();
-            for (final AccessSection s : different) {
+            Set<String> differentNames = new HashSet<>();
+            for (AccessSection s : different) {
               differentNames.add(s.getName());
             }
             return differentNames;
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 f5b26d1..63de389 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
@@ -16,7 +16,7 @@
 
 import com.google.gwt.i18n.client.Constants;
 
-interface ChangeConstants extends Constants {
+public interface ChangeConstants extends Constants {
   String previousChange();
   String nextChange();
   String openChange();
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 2673f49..60d21ba 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
@@ -146,6 +146,7 @@
   private boolean hasDraftComments;
   private CommentLinkProcessor commentLinkProcessor;
   private EditInfo edit;
+  private LocalComments lc;
 
   private List<HandlerRegistration> handlers = new ArrayList<>(4);
   private UpdateCheckTimer updateCheck;
@@ -232,6 +233,7 @@
     this.revision = normalize(revision);
     this.openReplyBox = openReplyBox;
     this.fileTableMode = mode;
+    this.lc = new LocalComments(changeId);
     add(uiBinder.createAndBindUi(this));
   }
 
@@ -363,7 +365,7 @@
         .openDiv()
         .append(Gerrit.info().change().replyLabel())
         .closeDiv());
-      if (hasDraftComments) {
+      if (hasDraftComments || lc.hasReplyComment()) {
         reply.setStyleName(style.highlight());
       }
       reply.setVisible(true);
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 d1ca517..01337ea 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
@@ -15,7 +15,9 @@
 package com.google.gerrit.client.change;
 
 import static com.google.gerrit.client.FormatUtil.formatAbsBytes;
+import static com.google.gerrit.client.FormatUtil.formatAbsPercentage;
 import static com.google.gerrit.client.FormatUtil.formatBytes;
+import static com.google.gerrit.client.FormatUtil.formatPercentage;
 
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
@@ -464,6 +466,7 @@
     private boolean hasNonBinaryFile;
     private int inserted;
     private int deleted;
+    private long binOldSize;
     private long bytesInserted;
     private long bytesDeleted;
 
@@ -520,6 +523,7 @@
     private void computeInsertedDeleted() {
       inserted = 0;
       deleted = 0;
+      binOldSize = 0;
       bytesInserted = 0;
       bytesDeleted = 0;
       for (int i = 0; i < list.length(); i++) {
@@ -531,6 +535,7 @@
             deleted += info.linesDeleted();
           } else {
             hasBinaryFile = true;
+            binOldSize += info.size() - info.sizeDelta();
             if (info.sizeDelta() >= 0) {
               bytesInserted += info.sizeDelta();
             } else {
@@ -771,6 +776,12 @@
         }
       } else if (info.binary()) {
         sb.append(formatBytes(info.sizeDelta()));
+        long oldSize = info.size() - info.sizeDelta();
+        if (oldSize != 0) {
+          sb.append(" (")
+            .append(formatPercentage(oldSize, info.sizeDelta()))
+            .append(")");
+        }
       }
       sb.closeTd();
     }
@@ -827,8 +838,17 @@
         if (hasNonBinaryFile) {
           sb.br();
         }
-        sb.append(Util.M.patchTableSize_ModifyBinaryFiles(
-            formatAbsBytes(bytesInserted), formatAbsBytes(bytesDeleted)));
+        if (binOldSize != 0) {
+          sb.append(Util.M.patchTableSize_ModifyBinaryFilesWithPercentages(
+              formatAbsBytes(bytesInserted),
+              formatAbsPercentage(binOldSize, bytesInserted),
+              formatAbsBytes(bytesDeleted),
+              formatAbsPercentage(binOldSize, bytesDeleted)));
+        } else {
+          sb.append(Util.M.patchTableSize_ModifyBinaryFiles(
+              formatAbsBytes(bytesInserted),
+              formatAbsBytes(bytesDeleted)));
+        }
       }
       sb.closeTh();
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
index 4139348..bba63c9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
@@ -48,20 +48,26 @@
 /** Displays a table of label and reviewer scores. */
 class Labels extends Grid {
   private static final String DATA_ID = "data-id";
-  private static final String REMOVE;
+  private static final String DATA_VOTE = "data-vote";
+  private static final String REMOVE_REVIEWER;
+  private static final String REMOVE_VOTE;
 
   static {
-    REMOVE = DOM.createUniqueId().replace('-', '_');
-    init(REMOVE);
+    REMOVE_REVIEWER = DOM.createUniqueId().replace('-', '_');
+    REMOVE_VOTE = DOM.createUniqueId().replace('-', '_');
+    init(REMOVE_REVIEWER, REMOVE_VOTE);
   }
 
-  private static final native void init(String r) /*-{
+  private static final native void init(String r, String v) /*-{
     $wnd[r] = $entry(function(e) {
-      @com.google.gerrit.client.change.Labels::onRemove(Lcom/google/gwt/dom/client/NativeEvent;)(e)
+      @com.google.gerrit.client.change.Labels::onRemoveReviewer(Lcom/google/gwt/dom/client/NativeEvent;)(e)
+    });
+    $wnd[v] = $entry(function(e) {
+      @com.google.gerrit.client.change.Labels::onRemoveVote(Lcom/google/gwt/dom/client/NativeEvent;)(e)
     });
   }-*/;
 
-  private static void onRemove(NativeEvent event) {
+  private static void onRemoveReviewer(NativeEvent event) {
     Integer user = getDataId(event);
     if (user != null) {
       final ChangeScreen screen = ChangeScreen.get(event);
@@ -77,6 +83,23 @@
     }
   }
 
+  private static void onRemoveVote(NativeEvent event) {
+    Integer user = getDataId(event);
+    String vote = getVoteId(event);
+    if (user != null && vote != null) {
+      final ChangeScreen screen = ChangeScreen.get(event);
+      ChangeApi.vote(screen.getChangeId().get(), user, vote).delete(
+          new GerritCallback<JavaScriptObject>() {
+            @Override
+            public void onSuccess(JavaScriptObject result) {
+              if (screen.isCurrentView()) {
+                Gerrit.display(PageLinks.toChange(screen.getChangeId()));
+              }
+            }
+          });
+    }
+  }
+
   private static Integer getDataId(NativeEvent event) {
     Element e = event.getEventTarget().cast();
     while (e != null) {
@@ -89,6 +112,18 @@
     return null;
   }
 
+  private static String getVoteId(NativeEvent event) {
+    Element e = event.getEventTarget().cast();
+    while (e != null) {
+      String v = e.getAttribute(DATA_VOTE);
+      if (!v.isEmpty()) {
+        return v;
+      }
+      e = e.getParentElement();
+    }
+    return null;
+  }
+
   private ChangeScreen.Style style;
 
   void init(ChangeScreen.Style style) {
@@ -97,6 +132,7 @@
 
   void set(ChangeInfo info) {
     List<String> names = new ArrayList<>(info.labels());
+    Set<Integer> removable = info.removableReviewerIds();
     Collections.sort(names);
 
     resize(names.size(), 2);
@@ -106,14 +142,14 @@
       LabelInfo label = info.label(name);
       setText(row, 0, name);
       if (label.all() != null) {
-        setWidget(row, 1, renderUsers(label));
+        setWidget(row, 1, renderUsers(label, removable));
       }
       getCellFormatter().setStyleName(row, 0, style.labelName());
       getCellFormatter().addStyleName(row, 0, getStyleForLabel(label));
     }
   }
 
-  private Widget renderUsers(LabelInfo label) {
+  private Widget renderUsers(LabelInfo label, Set<Integer> removable) {
     Map<Integer, List<ApprovalInfo>> m = new HashMap<>(4);
     int approved = 0;
     int rejected = 0;
@@ -150,8 +186,8 @@
         html.setStyleName(style.label_reject());
       }
       html.append(val).append(" ");
-      html.append(formatUserList(style, m.get(v),
-          Collections.<Integer> emptySet(), null));
+      html.append(formatUserList(style, m.get(v), removable,
+          label.name(), null));
       html.closeSpan();
     }
     return html.toBlockWidget();
@@ -198,6 +234,7 @@
   static SafeHtml formatUserList(ChangeScreen.Style style,
       Collection<? extends AccountInfo> in,
       Set<Integer> removable,
+      String label,
       Map<Integer, VotableInfo> votable) {
     List<AccountInfo> users = new ArrayList<>(in);
     Collections.sort(users, new Comparator<AccountInfo>() {
@@ -257,6 +294,9 @@
           .setAttribute(DATA_ID, ai._accountId())
           .setAttribute("title", getTitle(ai, votableCategories))
           .setStyleName(style.label_user());
+      if (label != null) {
+        html.setAttribute(DATA_VOTE, label);
+      }
       if (img != null) {
         html.openElement("img")
             .setStyleName(style.avatar())
@@ -271,10 +311,15 @@
       }
       html.append(name);
       if (removable.contains(ai._accountId())) {
-        html.openElement("button")
-            .setAttribute("title", Util.M.removeReviewer(name))
-            .setAttribute("onclick", REMOVE + "(event)")
-            .append("×")
+        html.openElement("button");
+        if (label != null) {
+          html.setAttribute("title", Util.M.removeVote(label))
+              .setAttribute("onclick", REMOVE_VOTE + "(event)");
+        } else {
+          html.setAttribute("title", Util.M.removeReviewer(name))
+              .setAttribute("onclick", REMOVE_REVIEWER + "(event)");
+        }
+        html.append("×")
             .closeElement("button");
       }
       html.closeSpan();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java
new file mode 100644
index 0000000..a0480d0
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java
@@ -0,0 +1,238 @@
+// 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.client.change;
+
+import com.google.gerrit.client.changes.CommentApi;
+import com.google.gerrit.client.changes.CommentInfo;
+import com.google.gerrit.client.diff.CommentRange;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwt.storage.client.Storage;
+import com.google.gwt.user.client.Cookies;
+
+import java.util.ArrayList;
+import java.util.Collection;
+
+public class LocalComments {
+  private final Change.Id changeId;
+  private final PatchSet.Id psId;
+  private final StorageBackend storage;
+
+  private static class InlineComment {
+    final PatchSet.Id psId;
+    final CommentInfo commentInfo;
+
+    InlineComment(PatchSet.Id psId, CommentInfo commentInfo) {
+      this.psId = psId;
+      this.commentInfo = commentInfo;
+    }
+  }
+
+  private static class StorageBackend {
+    private final Storage storageBackend;
+
+    StorageBackend() {
+      storageBackend = (Storage.isLocalStorageSupported())
+          ? Storage.getLocalStorageIfSupported()
+          : Storage.getSessionStorageIfSupported();
+    }
+
+    String getItem(String key) {
+      if (storageBackend == null) {
+        return Cookies.getCookie(key);
+      }
+      return storageBackend.getItem(key);
+    }
+
+    void setItem(String key, String value) {
+      if (storageBackend == null) {
+        Cookies.setCookie(key, value);
+        return;
+      }
+      storageBackend.setItem(key, value);
+    }
+
+    void removeItem(String key) {
+      if (storageBackend == null) {
+        Cookies.removeCookie(key);
+        return;
+      }
+      storageBackend.removeItem(key);
+    }
+
+    Collection<String> getKeys() {
+      if (storageBackend == null) {
+        return Cookies.getCookieNames();
+      }
+      ArrayList<String> result = new ArrayList<>(storageBackend.getLength());
+      for (int i = 0; i < storageBackend.getLength(); i++) {
+        result.add(storageBackend.key(i));
+      }
+      return result;
+    }
+  }
+
+  public LocalComments(Change.Id changeId) {
+    this.changeId = changeId;
+    this.psId = null;
+    this.storage = new StorageBackend();
+  }
+
+  public LocalComments(PatchSet.Id psId) {
+    this.changeId = psId.getParentKey();
+    this.psId = psId;
+    this.storage = new StorageBackend();
+  }
+
+  public String getReplyComment() {
+    String comment = storage.getItem(getReplyCommentName());
+    storage.removeItem(getReplyCommentName());
+    return comment;
+  }
+
+  public void setReplyComment(String comment) {
+    storage.setItem(getReplyCommentName(), comment.trim());
+  }
+
+  public boolean hasReplyComment() {
+    return storage.getKeys().contains(getReplyCommentName());
+  }
+
+  public void removeReplyComment() {
+    if (hasReplyComment()) {
+      storage.removeItem(getReplyCommentName());
+    }
+  }
+
+  private String getReplyCommentName() {
+    return "savedReplyComment-" + changeId.toString();
+  }
+
+  public static void saveInlineComments() {
+    final StorageBackend storage = new StorageBackend();
+    for (final String cookie : storage.getKeys()) {
+      if (isInlineComment(cookie)) {
+        GerritCallback<CommentInfo> cb = new GerritCallback<CommentInfo>() {
+          @Override
+          public void onSuccess(CommentInfo result) {
+            storage.removeItem(cookie);
+          }
+        };
+        InlineComment input = getInlineComment(cookie);
+        if (input.commentInfo.id() == null) {
+          CommentApi.createDraft(input.psId, input.commentInfo, cb);
+        } else {
+          CommentApi.updateDraft(input.psId, input.commentInfo.id(),
+              input.commentInfo, cb);
+        }
+      }
+    }
+  }
+
+  public void setInlineComment(CommentInfo comment) {
+    String name = getInlineCommentName(comment);
+    if (name == null) {
+      // Failed to get the store key -- so we can't continue.
+      return;
+    }
+    storage.setItem(name, comment.message().trim());
+  }
+
+  public boolean hasInlineComments() {
+    for (String cookie : storage.getKeys()) {
+      if (isInlineComment(cookie)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static boolean isInlineComment(String key) {
+    return key.startsWith("patchCommentEdit-") || key.startsWith("patchReply-")
+        || key.startsWith("patchComment-");
+  }
+
+  private static InlineComment getInlineComment(String key) {
+    String path;
+    Side side = Side.PARENT;
+    int line = 0;
+    CommentRange range;
+    StorageBackend storage = new StorageBackend();
+
+    String[] elements = key.split("-");
+    int offset = 1;
+    if (key.startsWith("patchReply-") || key.startsWith("patchCommentEdit-")) {
+      offset = 2;
+    }
+    Change.Id changeId = new Change.Id(Integer.parseInt(elements[offset + 0]));
+    PatchSet.Id psId =
+        new PatchSet.Id(changeId, Integer.parseInt(elements[offset + 1]));
+    path = atob(elements[offset + 2]);
+    side = (Side.PARENT.toString() == elements[offset + 3]) ? Side.PARENT
+        : Side.REVISION;
+    range = null;
+    if (elements[offset + 4].startsWith("R")) {
+      String rangeStart = elements[offset + 4].substring(1);
+      String rangeEnd = elements[offset + 5];
+      String[] split = rangeStart.split(",");
+      int sl = Integer.parseInt(split[0]);
+      int sc = Integer.parseInt(split[1]);
+      split = rangeEnd.split(",");
+      int el = Integer.parseInt(split[0]);
+      int ec = Integer.parseInt(split[1]);
+      range = CommentRange.create(sl, sc, el, ec);
+      line = sl;
+    } else {
+      line = Integer.parseInt(elements[offset + 4]);
+    }
+    CommentInfo info = CommentInfo.create(path, side, line, range);
+    info.message(storage.getItem(key));
+    if (key.startsWith("patchReply-")) {
+      info.inReplyTo(elements[1]);
+    } else if (key.startsWith("patchCommentEdit-")) {
+      info.id(elements[1]);
+    }
+    InlineComment inlineComment = new InlineComment(psId, info);
+    return inlineComment;
+  }
+
+  private String getInlineCommentName(CommentInfo comment) {
+    if (psId == null) {
+      return null;
+    }
+    String result = "patchComment-";
+    if (comment.id() != null) {
+      result = "patchCommentEdit-" + comment.id() + "-";
+    } else if (comment.inReplyTo() != null) {
+      result = "patchReply-" + comment.inReplyTo() + "-";
+    }
+    result += changeId + "-" + psId.getId() + "-" + btoa(comment.path()) + "-"
+        + comment.side() + "-";
+    if (comment.hasRange()) {
+      result += "R" + comment.range().startLine() + ","
+          + comment.range().startCharacter() + "-" + comment.range().endLine()
+          + "," + comment.range().endCharacter();
+    } else {
+      result += comment.line();
+    }
+    return result;
+  }
+
+  private static native String btoa(String a) /*-{ return btoa(a); }-*/;
+
+  private static native String atob(String b) /*-{ return atob(b); }-*/;
+}
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 2ec4b6b..2366e59 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
@@ -28,6 +28,7 @@
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.data.LabelValue;
@@ -75,7 +76,7 @@
 import java.util.Set;
 import java.util.TreeSet;
 
-class ReplyBox extends Composite {
+public class ReplyBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, ReplyBox> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
 
@@ -90,6 +91,7 @@
   private final String revision;
   private ReviewInput in = ReviewInput.create();
   private int labelHelpColumn;
+  private LocalComments lc;
 
   @UiField Styles style;
   @UiField TextArea message;
@@ -109,6 +111,7 @@
     this.clp = clp;
     this.psId = psId;
     this.revision = revision;
+    this.lc = new LocalComments(psId.getParentKey());
     initWidget(uiBinder.createAndBindUi(this));
 
     List<String> names = new ArrayList<>(permitted.keySet());
@@ -140,6 +143,10 @@
   protected void onLoad() {
     commentsPanel.setVisible(false);
     post.setEnabled(false);
+    if (lc.hasReplyComment()) {
+      message.setText(lc.getReplyComment());
+      lc.removeReplyComment();
+    }
     ChangeApi.drafts(psId.getParentKey().get())
         .get(new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
           @Override
@@ -201,6 +208,13 @@
               psId.getParentKey(),
               String.valueOf(psId.get())));
         }
+        @Override
+        public void onFailure(final Throwable caught) {
+          if (RestApi.isNotSignedIn(caught)) {
+            lc.setReplyComment(message.getText());
+          }
+          super.onFailure(caught);
+        }
       });
     hide();
   }
@@ -214,7 +228,7 @@
   void replyTo(MessageInfo msg) {
     if (msg.message() != null) {
       String t = message.getText();
-      String m = quote(msg);
+      String m = quote(removePatchSetHeaderLine(msg.message()));
       if (t == null || t.isEmpty()) {
         t = m;
       } else if (t.endsWith("\n\n")) {
@@ -224,20 +238,25 @@
       } else {
         t += "\n\n" + m;
       }
-      message.setText(t + "\n\n");
+      message.setText(t);
     }
   }
 
-  private static String quote(MessageInfo msg) {
-    String m = msg.message().trim();
-    if (m.startsWith("Patch Set ")) {
-      int i = m.indexOf('\n');
+  private static String removePatchSetHeaderLine(String msg) {
+    msg = msg.trim();
+    if (msg.startsWith("Patch Set ")) {
+      int i = msg.indexOf('\n');
       if (i > 0) {
-        m = m.substring(i + 1).trim();
+        msg = msg.substring(i + 1).trim();
       }
     }
+    return msg;
+  }
+
+  public static String quote(String msg) {
+    msg = msg.trim();
     StringBuilder quotedMsg = new StringBuilder();
-    for (String line : m.split("\\n")) {
+    for (String line : msg.split("\\n")) {
       line = line.trim();
       while (line.length() > 67) {
         int i = line.lastIndexOf(' ', 67);
@@ -253,7 +272,8 @@
       }
       quotedMsg.append(" > ").append(line).append("\n");
     }
-    return quotedMsg.toString().substring(0, quotedMsg.length() - 1); // remove last '\n'
+    quotedMsg.append("\n");
+    return quotedMsg.toString();
   }
 
   private void hide() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.ui.xml
index 52f6b6a..6903b91 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.ui.xml
@@ -38,7 +38,10 @@
       position: absolute;
       bottom: 5px;
       right: 5px;
+      background-color: #eee;
+      background-image: -webkit-linear-gradient(top, #eee, #eee);
     }
+    .cancel div { color: #444; }
     .comments {
       max-height: 275px;
       width: 526px;
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 3937ade..ae648dc 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
@@ -17,6 +17,7 @@
 import com.google.gerrit.client.ConfirmationCallback;
 import com.google.gerrit.client.ConfirmationDialog;
 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;
@@ -50,7 +51,6 @@
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
 
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 
@@ -177,10 +177,14 @@
 
           @Override
           public void onFailure(Throwable err) {
-            UIObject.setVisible(error, true);
-            error.setInnerText(err instanceof StatusCodeException
-                ? ((StatusCodeException) err).getEncodedResponse()
-                : err.getMessage());
+            if (isSigninFailure(err)) {
+              new NotSignedInDialog().center();
+            } else {
+              UIObject.setVisible(error, true);
+              error.setInnerText(err instanceof StatusCodeException
+                  ? ((StatusCodeException) err).getEncodedResponse()
+                  : err.getMessage());
+            }
           }
         });
   }
@@ -209,20 +213,13 @@
       cc.remove(i);
     }
     cc.remove(info.owner()._accountId());
-
-    Set<Integer> removable = new HashSet<>();
-    if (info.removableReviewers() != null) {
-      for (AccountInfo a : Natives.asList(info.removableReviewers())) {
-        removable.add(a._accountId());
-      }
-    }
-
+    Set<Integer> removable = info.removableReviewerIds();
     Map<Integer, VotableInfo> votable = votable(info);
 
     SafeHtml rHtml = Labels.formatUserList(style,
-        r.values(), removable, votable);
+        r.values(), removable, null, votable);
     SafeHtml ccHtml = Labels.formatUserList(style,
-        cc.values(), removable, votable);
+        cc.values(), removable, null, votable);
 
     reviewersText.setInnerSafeHtml(rHtml);
     ccText.setInnerSafeHtml(ccHtml);
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 9cafeec..8314e3e 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
@@ -155,6 +155,10 @@
         .addParameter("n", n);
   }
 
+  public static RestApi vote(int id, int reviewer, String vote) {
+    return reviewer(id, reviewer).view("votes").id(vote);
+  }
+
   public static RestApi reviewer(int id, int reviewer) {
     return change(id).view("reviewers").id(reviewer);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
index ef74a65..eb09657 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
@@ -36,11 +36,14 @@
   String patchTableSize_Modify(int insertions, int deletions);
   String patchTableSize_ModifyBinaryFiles(String bytesInserted,
       String bytesDeleted);
+  String patchTableSize_ModifyBinaryFilesWithPercentages(String bytesInserted,
+      String percentageInserted, String bytesDeleted, String percentageDeleted);
   String patchTableSize_LongModify(int insertions, int deletions);
   String patchTableSize_Lines(@PluralCount int insertions);
 
   String removeHashtag(String name);
   String removeReviewer(String fullName);
+  String removeVote(String label);
   String messageWrittenOn(String date);
 
   String renamedFrom(String sourcePath);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
index 67ef2c3..b3c3980 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
@@ -18,11 +18,13 @@
 patchTableDrafts = {0} drafts
 patchTableSize_Modify = +{0}, -{1}
 patchTableSize_ModifyBinaryFiles = +{0}, -{1}
+patchTableSize_ModifyBinaryFilesWithPercentages = +{0} (+{1}), -{2} (-{3})
 patchTableSize_LongModify = {0} inserted, {1} deleted
 patchTableSize_Lines = {0} lines
 
 removeHashtag = Remove hashtag {0}
 removeReviewer = Remove reviewer {0}
+removeVote = Remove vote {0}
 messageWrittenOn = on {0}
 
 renamedFrom = renamed from {0}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
index be64a3d..8750389 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
@@ -38,7 +38,7 @@
 
   /** Handler that can receive notifications of a change's starred status. */
   public static interface ChangeStarHandler {
-    public void onChangeStar(ChangeStarEvent event);
+    void onChangeStar(ChangeStarEvent event);
   }
 
   /** Event fired when a star changes status. The new status is reported. */
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
index 21b2f50..86d149a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.client.diff;
 
 import com.google.gerrit.client.FormatUtil;
+import com.google.gerrit.client.change.LocalComments;
 import com.google.gerrit.client.changes.CommentApi;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
@@ -190,7 +192,7 @@
     setRangeHighlight(edit);
     if (edit) {
       String msg = comment.message() != null
-          ? comment.message().trim()
+          ? comment.message()
           : "";
       editArea.setValue(msg);
       cancel.setVisible(!isNew());
@@ -289,6 +291,7 @@
     enableEdit(false);
 
     pendingGroup = group;
+    final LocalComments lc = new LocalComments(psId);
     GerritCallback<CommentInfo> cb = new GerritCallback<CommentInfo>() {
       @Override
       public void onSuccess(CommentInfo result) {
@@ -306,6 +309,11 @@
       public void onFailure(Throwable e) {
         enableEdit(true);
         pendingGroup = null;
+        if (RestApi.isNotSignedIn(e)) {
+          CommentInfo saved = CommentInfo.copy(comment);
+          saved.message(editArea.getValue().trim());
+          lc.setInlineComment(saved);
+        }
         super.onFailure(e);
       }
     };
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 eec3d0c..3d7607b 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
@@ -74,6 +74,8 @@
   @UiField CheckBox reviewed;
   @UiField Element project;
   @UiField Element filePath;
+  @UiField Element fileNumber;
+  @UiField Element fileCount;
 
   @UiField Element noDiff;
   @UiField FlowPanel linkPanel;
@@ -143,6 +145,9 @@
       public void onSuccess(NativeMap<FileInfo> result) {
         JsArray<FileInfo> files = result.values();
         FileInfo.sortFileInfoByPath(files);
+        fileNumber.setInnerText(
+            Integer.toString(Natives.asList(files).indexOf(result.get(path)) + 1));
+        fileCount.setInnerText(Integer.toString(files.length()));
         int index = 0; // TODO: Maybe use patchIndex.
         for (int i = 0; i < files.length(); i++) {
           if (path.equals(files.get(i).path())) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.ui.xml
index f13c9a3..39eb6cb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.ui.xml
@@ -34,6 +34,11 @@
   .path {
     white-space: nowrap;
   }
+  .fileCount {
+    white-space: nowrap;
+    position: relative;
+    bottom: 4px;
+  }
   .navigation {
     position: absolute;
     top: 0;
@@ -69,6 +74,9 @@
     <div class='{style.navigation}'>
       <span ui:field='noDiff' class='{style.nodiff}'><ui:msg>No Differences</ui:msg></span>
       <g:FlowPanel ui:field='linkPanel' styleName='{style.linkPanel}'/>
+      <span class='{style.fileCount}'>
+        <ui:msg>File <span ui:field='fileNumber'/> of <span ui:field='fileCount'/></ui:msg>
+      </span>
       <x:InlineHyperlink ui:field='prev' styleName='{res.style.goPrev}'/>
       <x:InlineHyperlink ui:field='up'
           styleName='{res.style.goUp}'
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 91baf90..f363f9b 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
@@ -20,7 +20,6 @@
 import static com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace.IGNORE_LEADING_AND_TRAILING;
 import static com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace.IGNORE_NONE;
 import static com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace.IGNORE_TRAILING;
-
 import static com.google.gwt.event.dom.client.KeyCodes.KEY_ESCAPE;
 
 import com.google.gerrit.client.Gerrit;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
index 48b4c3c..f5bcaa6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.FormatUtil;
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.change.ReplyBox;
 import com.google.gerrit.client.changes.CommentApi;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.changes.Util;
@@ -143,17 +144,21 @@
     replyBox.setEdit(true);
   }
 
-  void addReplyBox() {
+  void addReplyBox(boolean quote) {
+    CommentInfo commentReply = CommentInfo.createReply(comment);
+    if (quote) {
+      commentReply.message(ReplyBox.quote(comment.message()));
+    }
     getCommentManager().addDraftBox(
       getCm().side(),
-      CommentInfo.createReply(comment)).setEdit(true);
+      commentReply).setEdit(true);
   }
 
   void doReply() {
     if (!Gerrit.isSignedIn()) {
       Gerrit.doSignIn(getCommentManager().getSideBySide().getToken());
     } else if (replyBox == null) {
-      addReplyBox();
+      addReplyBox(false);
     } else {
       openReplyBox();
     }
@@ -165,6 +170,15 @@
     doReply();
   }
 
+  @UiHandler("quote")
+  void onQuote(ClickEvent e) {
+    e.stopPropagation();
+    if (!Gerrit.isSignedIn()) {
+      Gerrit.doSignIn(getCommentManager().getSideBySide().getToken());
+    }
+    addReplyBox(true);
+  }
+
   @UiHandler("done")
   void onReplyDone(ClickEvent e) {
     e.stopPropagation();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml
index 46b76ca..cbea847 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.ui.xml
@@ -61,6 +61,11 @@
           <ui:attribute name='title'/>
           <div><ui:msg>Reply</ui:msg></div>
         </g:Button>
+        <g:Button ui:field='quote' styleName=''
+            title='Reply to this comment with quoting it'>
+          <ui:attribute name='title'/>
+          <div><ui:msg>Quote</ui:msg></div>
+        </g:Button>
         <g:Button ui:field='done' styleName=''
             title='Reply "Done" to this comment'>
           <ui:attribute name='title'/>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.css
index 383f278..26f8ff5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.css
@@ -27,12 +27,14 @@
 
 .delete {
   background-color: #faa;
+  min-width: 12px;
 }
 .insert {
   background-color: #9f9;
+  min-width: 12px;
 }
 .edit {
-  border-left: 3px solid #faa;
-  width: 2px !important;
+  border-left: 6px solid #faa;
+  width: 6px !important;
   background-color: #9f9;
 }
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 a511550..ebce1c0 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
@@ -307,7 +307,15 @@
 }
 .searchPanel .searchTextBox {
   font-size: 9pt;
-  margin: 5.286px 3px 0 0;
+  margin: 8.286px 3px 0 0;
+}
+.searchPanel .searchDropdown {
+  font-size: 8pt;
+  border: 2px solid;
+  border-color: rgba(0, 0, 0, 0.15);
+  height: 16px;
+  border-radius: 2px;
+  box-sizing: content-box;
 }
 .searchPanel .searchButton {
   text-align: center;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorPanel.java
index 81494c0..3cf29e5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorPanel.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client.patches;
 
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.change.LocalComments;
 import com.google.gerrit.client.changes.CommentApi;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
@@ -243,6 +244,7 @@
     discard.setEnabled(false);
 
     final PatchSet.Id psId = comment.getKey().getParentKey().getParentKey();
+    final LocalComments lc = new LocalComments(psId);
     final boolean wasNew = isNew();
     GerritCallback<CommentInfo> cb = new GerritCallback<CommentInfo>() {
       @Override
@@ -264,6 +266,7 @@
         save.setEnabled(true);
         cancel.setEnabled(true);
         discard.setEnabled(true);
+        lc.setInlineComment(toInput(comment));
         super.onFailure(caught);
         onSave.onFailure(caught);
       }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/NavLinks.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/NavLinks.java
index c7f9859..adfaa04 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/NavLinks.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/NavLinks.java
@@ -26,6 +26,7 @@
 import com.google.gwt.user.client.ui.Grid;
 import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
 import com.google.gwt.user.client.ui.HasHorizontalAlignment;
+import com.google.gwt.user.client.ui.Label;
 import com.google.gwtexpui.globalkey.client.KeyCommand;
 import com.google.gwtexpui.globalkey.client.KeyCommandSet;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
@@ -35,7 +36,7 @@
 class NavLinks extends Composite {
   public enum Nav {
     PREV (0, '[', PatchUtil.C.previousFileHelp(), 0),
-    NEXT (3, ']', PatchUtil.C.nextFileHelp(), 1);
+    NEXT (4, ']', PatchUtil.C.nextFileHelp(), 1);
 
     public int col;      // Table Cell column to display link in
     public int key;      // key code shortcut to activate link
@@ -59,7 +60,7 @@
   NavLinks(KeyCommandSet kcs, PatchSet.Id forPatch) {
     patchSetId = forPatch;
     keys = kcs;
-    table = new Grid(1, 4);
+    table = new Grid(1, 5);
     initWidget(table);
 
     final CellFormatter fmt = table.getCellFormatter();
@@ -68,6 +69,7 @@
     fmt.setHorizontalAlignment(0, 1, HasHorizontalAlignment.ALIGN_CENTER);
     fmt.setHorizontalAlignment(0, 2, HasHorizontalAlignment.ALIGN_RIGHT);
     fmt.setHorizontalAlignment(0, 3, HasHorizontalAlignment.ALIGN_RIGHT);
+    fmt.setHorizontalAlignment(0, 4, HasHorizontalAlignment.ALIGN_RIGHT);
 
     final ChangeLink up = new ChangeLink("", patchSetId);
     SafeHtml.set(up, SafeHtml.asis(Util.C.upToChangeIconLink()));
@@ -77,6 +79,10 @@
   void display(int patchIndex, PatchTable fileList,
       List<InlineHyperlink> links, List<WebLinkInfo> webLinks) {
     if (fileList != null) {
+      Label fileCountLabel =
+          new Label(Gerrit.M.fileCount(patchIndex + 1, fileList.size()));
+      fileCountLabel.setStyleName(Gerrit.RESOURCES.css().nowrap());
+      table.setWidget(0, 3, fileCountLabel);
       setupNav(Nav.PREV, fileList.getPreviousPatchLink(patchIndex));
       setupNav(Nav.NEXT, fileList.getNextPatchLink(patchIndex));
     } else {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchTable.java
index d84f799..6c500d8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchTable.java
@@ -102,6 +102,10 @@
     return i != null ? i : -1;
   }
 
+  int size() {
+    return patchMap.size();
+  }
+
   private Map<Patch.Key, Integer> patchMap() {
     if (patchMap == null) {
       patchMap = new HashMap<>();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java
index bccd237..daac7cf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java
@@ -38,15 +38,10 @@
   }
 
   public static void showFailure(Throwable caught) {
-    if (isNotSignedIn(caught) || isInvalidXSRF(caught)) {
+    if (isSigninFailure(caught)) {
       new NotSignedInDialog().center();
-
     } else if (isNoSuchEntity(caught)) {
-      if (Gerrit.isSignedIn()) {
-        new ErrorDialog(Gerrit.C.notFoundBody()).center();
-      } else {
-        new NotSignedInDialog().center();
-      }
+      new ErrorDialog(Gerrit.C.notFoundBody()).center();
     } else if (isInactiveAccount(caught)) {
       new ErrorDialog(Gerrit.C.inactiveAccountBody()).center();
 
@@ -77,12 +72,20 @@
     }
   }
 
-  private static boolean isInvalidXSRF(final Throwable caught) {
+  public static boolean isSigninFailure(Throwable caught) {
+    if (isNotSignedIn(caught) || isInvalidXSRF(caught) ||
+        (isNoSuchEntity(caught) && !Gerrit.isSignedIn())) {
+      return true;
+    }
+    return false;
+  }
+
+  protected static boolean isInvalidXSRF(final Throwable caught) {
     return caught instanceof InvocationException
         && caught.getMessage().equals(JsonConstants.ERROR_INVALID_XSRF);
   }
 
-  private static boolean isNotSignedIn(Throwable caught) {
+  protected static boolean isNotSignedIn(Throwable caught) {
     return RestApi.isNotSignedIn(caught)
         || (caught instanceof RemoteJsonException
            && caught.getMessage().equals(NotSignedInException.MESSAGE));
@@ -99,17 +102,17 @@
         && caught.getMessage().startsWith(InactiveAccountException.MESSAGE);
   }
 
-  private static boolean isNoSuchAccount(final Throwable caught) {
+  protected static boolean isNoSuchAccount(final Throwable caught) {
     return caught instanceof RemoteJsonException
         && caught.getMessage().startsWith(NoSuchAccountException.MESSAGE);
   }
 
-  private static boolean isNameAlreadyUsed(final Throwable caught) {
+  protected static boolean isNameAlreadyUsed(final Throwable caught) {
     return caught instanceof RemoteJsonException
         && caught.getMessage().startsWith(NameAlreadyUsedException.MESSAGE);
   }
 
-  private static boolean isNoSuchGroup(final Throwable caught) {
+  protected static boolean isNoSuchGroup(final Throwable caught) {
     return caught instanceof RemoteJsonException
     && caught.getMessage().startsWith(NoSuchGroupException.MESSAGE);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java
index 8128afe..97ed559 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java
@@ -44,12 +44,10 @@
 
   @Override
   public void onFailure(final Throwable caught) {
-    if (isNoSuchEntity(caught)) {
-      if (Gerrit.isSignedIn()) {
-        Gerrit.display(screen.getToken(), new NotFoundScreen());
-      } else {
-        new NotSignedInDialog().center();
-      }
+    if (isSigninFailure(caught)) {
+      new NotSignedInDialog().center();
+    } else if (isNoSuchEntity(caught)) {
+      Gerrit.display(screen.getToken(), new NotFoundScreen());
     } else {
       super.onFailure(caught);
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java
index 8b58403..36708dc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java
@@ -43,7 +43,8 @@
   private HorizontalPanel filterPanel;
   private String match;
   private Query query;
-  private Button close;
+  private Button closeTop;
+  private Button closeBottom;
   private ScrollPanel sp;
   private PopupPanel.PositionCallback popupPosition;
   private int preferredTop;
@@ -55,10 +56,11 @@
     createWidgets(popupText, currentPageLink);
     final FlowPanel pfp = new FlowPanel();
     pfp.add(filterPanel);
+    pfp.add(closeTop);
     sp = new ScrollPanel(projectsTab);
     sp.setSize("100%", "100%");
     pfp.add(sp);
-    pfp.add(close);
+    pfp.add(closeBottom);
     popup.setWidget(pfp);
     popup.setHeight("100%");
     popupPosition = getPositionCallback();
@@ -147,17 +149,23 @@
     };
     projectsTab.setSavePointerId(currentPageLink);
 
-    close = new Button(Util.C.projectsClose());
+    closeTop = createCloseButton();
+    closeBottom = createCloseButton();
+
+    popup = new DialogBox();
+    popup.setModal(false);
+    popup.setText(popupText);
+  }
+
+  private Button createCloseButton() {
+    Button close = new Button(Util.C.projectsClose());
     close.addClickHandler(new ClickHandler() {
       @Override
       public void onClick(final ClickEvent event) {
         closePopup();
       }
     });
-
-    popup = new DialogBox();
-    popup.setModal(false);
-    popup.setText(popupText);
+    return close;
   }
 
   public void displayPopup() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadHandler.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadHandler.java
index a91becf..9a5eb03 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadHandler.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ScreenLoadHandler.java
@@ -17,5 +17,5 @@
 import com.google.gwt.event.shared.EventHandler;
 
 public interface ScreenLoadHandler extends EventHandler {
-  public void onScreenLoad(ScreenLoadEvent event);
+  void onScreenLoad(ScreenLoadEvent event);
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
index 639e5e7..d0c6f9d 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
@@ -401,23 +401,23 @@
   }
 
   public interface EventHandler {
-    public void handle(CodeMirror instance, NativeEvent event);
+    void handle(CodeMirror instance, NativeEvent event);
   }
 
   public interface RenderLineHandler {
-    public void handle(CodeMirror instance, LineHandle handle, Element element);
+    void handle(CodeMirror instance, LineHandle handle, Element element);
   }
 
   public interface GutterClickHandler {
-    public void handle(CodeMirror instance, int line, String gutter,
+    void handle(CodeMirror instance, int line, String gutter,
         NativeEvent clickEvent);
   }
 
   public interface BeforeSelectionChangeHandler {
-    public void handle(CodeMirror instance, Pos anchor, Pos head);
+    void handle(CodeMirror instance, Pos anchor, Pos head);
   }
 
   public interface ChangesHandler {
-    public void handle(CodeMirror instance);
+    void handle(CodeMirror instance);
   }
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/style.css b/gerrit-gwtui/src/main/java/net/codemirror/lib/style.css
index 6ce70db..4b97da1 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/style.css
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/style.css
@@ -18,6 +18,7 @@
 @external .CodeMirror-linenumber;
 @external .CodeMirror-overlayscroll-horizontal;
 @external .CodeMirror-overlayscroll-vertical;
+@external .CodeMirror-scrollbar-filler;
 @external .cm-tab;
 @external .cm-searching;
 @external .cm-trailingspace;
@@ -38,7 +39,17 @@
 .CodeMirror-overlayscroll-vertical div {
   min-height: 25px;
 }
-
+/* Ensure the scrollbars are not too narrow */
+.CodeMirror-overlayscroll-horizontal {
+  min-height: 12px;
+}
+.CodeMirror-overlayscroll-vertical {
+  min-width: 12px;
+}
+.CodeMirror-scrollbar-filler {
+  min-height: 12px;
+  min-width: 12px;
+}
 /* Stack the scrollbar so annotations can receive clicks. */
 .CodeMirror-overlayscroll-vertical {
   z-index: inherit;
diff --git a/gerrit-gwtui/src/test/java/com/google/gerrit/client/FormatUtilTest.java b/gerrit-gwtui/src/test/java/com/google/gerrit/client/FormatUtilTest.java
index 0f659c9a..04dccdb 100644
--- a/gerrit-gwtui/src/test/java/com/google/gerrit/client/FormatUtilTest.java
+++ b/gerrit-gwtui/src/test/java/com/google/gerrit/client/FormatUtilTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client;
 
 import static com.google.gerrit.client.FormatUtil.formatBytes;
+import static com.google.gerrit.client.FormatUtil.formatPercentage;
 import static org.junit.Assert.assertEquals;
 
 import com.googlecode.gwt.test.GwtModule;
@@ -45,4 +46,17 @@
     assertEquals("-27 B", formatBytes(-27));
     assertEquals("-1.7 MiB", formatBytes(-1728));
   }
+
+  @Test
+  public void testFormatPercentage() {
+    assertEquals("N/A", formatPercentage(0, 10));
+    assertEquals("0%", formatPercentage(100, 0));
+    assertEquals("+25%", formatPercentage(100, 25));
+    assertEquals("-25%", formatPercentage(100, -25));
+    assertEquals("+50%", formatPercentage(100, 50));
+    assertEquals("-50%", formatPercentage(100, -50));
+    assertEquals("+100%", formatPercentage(100, 100));
+    assertEquals("-100%", formatPercentage(100, -100));
+    assertEquals("+500%", formatPercentage(100, 500));
+  }
 }
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
index adfe86c..c1a0f44 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java
@@ -14,20 +14,36 @@
 
 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(boolean headless, boolean slave) {
+  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;
+    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 e1810ef..ab6cc90 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,6 +14,8 @@
 
 package com.google.gerrit.httpd;
 
+import static com.google.gerrit.reviewdb.client.AuthType.OAUTH;
+
 import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.DownloadConfig;
@@ -40,7 +42,11 @@
     if (authConfig.isTrustContainerAuth()) {
       authFilter = ContainerAuthFilter.class;
     } else if (authConfig.isGitBasicAuth()) {
-      authFilter = ProjectBasicAuthFilter.class;
+      if (authConfig.getAuthType() == OAUTH) {
+        authFilter = ProjectOAuthFilter.class;
+      } else {
+        authFilter = ProjectBasicAuthFilter.class;
+      }
     } else {
       authFilter = ProjectDigestFilter.class;
     }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 89d62dd..13b1a48 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -23,15 +23,14 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.AsyncReceiveCommits;
 import com.google.gerrit.server.git.ChangeCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ReceiveCommits;
-import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.UploadPackMetricsHook;
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -50,8 +49,6 @@
 import org.eclipse.jgit.http.server.resolver.AsIsFileService;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.PostReceiveHook;
-import org.eclipse.jgit.transport.PostReceiveHookChain;
 import org.eclipse.jgit.transport.PreUploadHook;
 import org.eclipse.jgit.transport.PreUploadHookChain;
 import org.eclipse.jgit.transport.ReceivePack;
@@ -200,11 +197,15 @@
 
   static class UploadFactory implements UploadPackFactory<HttpServletRequest> {
     private final TransferConfig config;
+    private final UploadPackMetricsHook uploadMetrics;
     private final DynamicSet<PreUploadHook> preUploadHooks;
 
     @Inject
-    UploadFactory(TransferConfig tc, DynamicSet<PreUploadHook> preUploadHooks) {
+    UploadFactory(TransferConfig tc,
+        UploadPackMetricsHook uploadMetrics,
+        DynamicSet<PreUploadHook> preUploadHooks) {
       this.config = tc;
+      this.uploadMetrics = uploadMetrics;
       this.preUploadHooks = preUploadHooks;
     }
 
@@ -215,6 +216,7 @@
       up.setTimeout(config.getTimeout());
       up.setPreUploadHook(PreUploadHookChain.newChain(
           Lists.newArrayList(preUploadHooks)));
+      up.setPostUploadHook(uploadMetrics);
       return up;
     }
   }
@@ -273,18 +275,10 @@
 
   static class ReceiveFactory implements ReceivePackFactory<HttpServletRequest> {
     private final AsyncReceiveCommits.Factory factory;
-    private final TransferConfig config;
-    private DynamicSet<ReceivePackInitializer> receivePackInitializers;
-    private DynamicSet<PostReceiveHook> postReceiveHooks;
 
     @Inject
-    ReceiveFactory(AsyncReceiveCommits.Factory factory, TransferConfig config,
-        DynamicSet<ReceivePackInitializer> receivePackInitializers,
-        DynamicSet<PostReceiveHook> postReceiveHooks) {
+    ReceiveFactory(AsyncReceiveCommits.Factory factory) {
       this.factory = factory;
-      this.config = config;
-      this.receivePackInitializers = receivePackInitializers;
-      this.postReceiveHooks = postReceiveHooks;
     }
 
     @Override
@@ -297,24 +291,13 @@
         throw new ServiceNotAuthorizedException();
       }
 
-      final IdentifiedUser user = pc.getUser().asIdentifiedUser();
-      final ReceiveCommits rc = factory.create(pc, db).getReceiveCommits();
+      ReceiveCommits rc = factory.create(pc, db).getReceiveCommits();
+      rc.init();
+
       ReceivePack rp = rc.getReceivePack();
-      rp.setRefLogIdent(user.newRefLogIdent());
-      rp.setTimeout(config.getTimeout());
-      rp.setMaxObjectSizeLimit(config.getMaxObjectSizeLimit());
-      init(pc.getProject().getNameKey(), rp);
-      rp.setPostReceiveHook(PostReceiveHookChain.newChain(
-          Lists.newArrayList(postReceiveHooks)));
       req.setAttribute(ATT_RC, rc);
       return rp;
     }
-
-    private void init(Project.NameKey project, ReceivePack rp) {
-      for (ReceivePackInitializer initializer : receivePackInitializers) {
-        initializer.init(project, rp);
-      }
-    }
   }
 
   static class DisabledReceiveFactory implements
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
new file mode 100644
index 0000000..75f3b2c
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -0,0 +1,336 @@
+// 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.httpd;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.util.Locale;
+import java.util.NoSuchElementException;
+
+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.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+
+/**
+ * Authenticates the current user with an OAuth2 server.
+ *
+ * @see <a href="https://tools.ietf.org/rfc/rfc6750.txt">RFC 6750</a>
+ */
+@Singleton
+class ProjectOAuthFilter implements Filter {
+
+  private static final Logger log = LoggerFactory
+      .getLogger(ProjectOAuthFilter.class);
+
+  private static final String REALM_NAME = "Gerrit Code Review";
+  private static final String AUTHORIZATION = "Authorization";
+  private static final String BASIC = "Basic ";
+  private static final String GIT_COOKIE_PREFIX = "git-";
+
+  private final DynamicItem<WebSession> session;
+  private final DynamicMap<OAuthLoginProvider> loginProviders;
+  private final AccountCache accountCache;
+  private final AccountManager accountManager;
+  private final String gitOAuthProvider;
+  private final boolean userNameToLowerCase;
+
+  private String defaultAuthPlugin;
+  private String defaultAuthProvider;
+
+  @Inject
+  ProjectOAuthFilter(DynamicItem<WebSession> session,
+      DynamicMap<OAuthLoginProvider> pluginsProvider,
+      AccountCache accountCache,
+      AccountManager accountManager,
+      @GerritServerConfig Config gerritConfig) {
+    this.session = session;
+    this.loginProviders = pluginsProvider;
+    this.accountCache = accountCache;
+    this.accountManager = accountManager;
+    this.gitOAuthProvider =
+        gerritConfig.getString("auth", null, "gitOAuthProvider");
+    this.userNameToLowerCase =
+        gerritConfig.getBoolean("auth", null, "userNameToLowerCase", false);
+  }
+
+  @Override
+  public void init(FilterConfig config) throws ServletException {
+    if (Strings.isNullOrEmpty(gitOAuthProvider)) {
+      pickOnlyProvider();
+    } else {
+      pickConfiguredProvider();
+    }
+  }
+
+  @Override
+  public void destroy() {
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response,
+      FilterChain chain) throws IOException, ServletException {
+    HttpServletRequest req = (HttpServletRequest) request;
+    Response rsp = new Response((HttpServletResponse) response);
+    if (verify(req, rsp)) {
+      chain.doFilter(req, rsp);
+    }
+  }
+
+  private boolean verify(HttpServletRequest req, Response rsp)
+      throws IOException {
+    AuthInfo authInfo = null;
+
+    // first check if there is a suitable git cookie; such cookies are
+    // expected to have names starting with the prefix git-
+    if (req.getCookies() != null) {
+      for (Cookie cookie: req.getCookies()) {
+        if (cookie.getName().startsWith(GIT_COOKIE_PREFIX)) {
+          authInfo = extractAuthInfo(cookie);
+          if (authInfo != null) {
+            break;
+          }
+        }
+      }
+    }
+
+    // if there is no suitable git cookie fall back to Basic authentication
+    if (authInfo == null) {
+      String hdr = req.getHeader(AUTHORIZATION);
+      if (hdr == null || !hdr.startsWith(BASIC)) {
+        // Allow an anonymous connection through, or it might be using a
+        // session cookie instead of basic authentication.
+        return true;
+      }
+
+      byte[] decoded = Base64.decodeBase64(hdr.substring(BASIC.length()));
+      String usernamePassword = new String(decoded, encoding(req));
+      int splitPos = usernamePassword.indexOf(':');
+      if (splitPos < 1) {
+        rsp.sendError(SC_UNAUTHORIZED);
+        return false;
+      }
+
+      authInfo = new AuthInfo(usernamePassword.substring(0, splitPos),
+          usernamePassword.substring(splitPos + 1),
+          defaultAuthPlugin, defaultAuthProvider);
+    }
+
+    if (Strings.isNullOrEmpty(authInfo.tokenOrSecret)) {
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+
+    AccountState who = accountCache.getByUsername(authInfo.username);
+    if (who == null || !who.getAccount().isActive()) {
+      log.warn("Authentication failed for " + authInfo.username
+          + ": account inactive or not provisioned in Gerrit");
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+
+    AuthRequest authRequest = AuthRequest.forExternalUser(
+        authInfo.username);
+    authRequest.setEmailAddress(who.getAccount().getPreferredEmail());
+    authRequest.setDisplayName(who.getAccount().getFullName());
+    authRequest.setPassword(authInfo.tokenOrSecret);
+    authRequest.setAuthPlugin(authInfo.pluginName);
+    authRequest.setAuthProvider(authInfo.exportName);
+
+    try {
+      AuthResult authResult = accountManager.authenticate(authRequest);
+      WebSession ws = session.get();
+      ws.setUserAccountId(authResult.getAccountId());
+      ws.setAccessPathOk(AccessPath.GIT, true);
+      ws.setAccessPathOk(AccessPath.REST_API, true);
+      return true;
+    } catch (AccountException e) {
+      log.warn("Authentication failed for " + authInfo.username, e);
+      rsp.sendError(SC_UNAUTHORIZED);
+      return false;
+    }
+  }
+
+  /**
+   * Picks the only installed OAuth provider. If there is a multiude
+   * of providers available, the actual provider must be determined
+   * from the authentication request.
+   *
+   * @throws ServletException if there is no {@code OAuthLoginProvider}
+   * installed at all.
+   */
+  private void pickOnlyProvider() throws ServletException {
+    try {
+      Entry<OAuthLoginProvider> loginProvider =
+          Iterables.getOnlyElement(loginProviders);
+      defaultAuthPlugin = loginProvider.getPluginName();
+      defaultAuthProvider = loginProvider.getExportName();
+    } catch (NoSuchElementException e) {
+      throw new ServletException("No OAuth login provider installed");
+    } catch (IllegalArgumentException e) {
+      // multiple providers found => do not pick any
+    }
+  }
+
+  /**
+   * Picks the {@code OAuthLoginProvider} configured with
+   * <tt>auth.gitOAuthProvider</tt>.
+   *
+   * @throws ServletException if the configured provider was not found.
+   */
+  private void pickConfiguredProvider() throws ServletException {
+    int splitPos = gitOAuthProvider.lastIndexOf(':');
+    if (splitPos < 1 || splitPos == gitOAuthProvider.length() - 1) {
+      // no colon at all or leading/trailing colon: malformed providerId
+      throw new ServletException("OAuth login provider configuration is"
+          + " invalid: Must be of the form pluginName:providerName");
+    }
+    defaultAuthPlugin= gitOAuthProvider.substring(0, splitPos);
+    defaultAuthProvider = gitOAuthProvider.substring(splitPos + 1);
+    OAuthLoginProvider provider = loginProviders.get(defaultAuthPlugin,
+        defaultAuthProvider);
+    if (provider == null) {
+      throw new ServletException("Configured OAuth login provider "
+          + gitOAuthProvider + " wasn't installed");
+    }
+  }
+
+  private AuthInfo extractAuthInfo(Cookie cookie)
+      throws UnsupportedEncodingException {
+    String username = URLDecoder.decode(cookie.getName()
+        .substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
+    String value = cookie.getValue();
+    int splitPos = value.lastIndexOf('@');
+    if (splitPos < 1 || splitPos == value.length() - 1) {
+      // no providerId in the cookie value => assume default provider
+      // note: a leading/trailing at sign is considered to belong to
+      // the access token rather than being a separator
+      return new AuthInfo(username, cookie.getValue(),
+          defaultAuthPlugin, defaultAuthProvider);
+    }
+    String token = value.substring(0, splitPos);
+    String providerId = value.substring(splitPos + 1);
+    splitPos = providerId.lastIndexOf(':');
+    if (splitPos < 1 || splitPos == providerId.length() - 1) {
+      // no colon at all or leading/trailing colon: malformed providerId
+      return null;
+    }
+    String pluginName = providerId.substring(0, splitPos);
+    String exportName = providerId.substring(splitPos + 1);
+    OAuthLoginProvider provider = loginProviders.get(pluginName, exportName);
+    if (provider == null) {
+      return null;
+    }
+    return new AuthInfo(username, token, pluginName, exportName);
+  }
+
+  private static String encoding(HttpServletRequest req) {
+    return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
+  }
+
+  private class AuthInfo {
+    private final String username;
+    private final String tokenOrSecret;
+    private final String pluginName;
+    private final String exportName;
+
+    private AuthInfo(String username, String tokenOrSecret,
+        String pluginName, String exportName) {
+      this.username = userNameToLowerCase
+          ? username.toLowerCase(Locale.US)
+          : username;
+      this.tokenOrSecret = tokenOrSecret;
+      this.pluginName = pluginName;
+      this.exportName = exportName;
+    }
+  }
+
+  private static class Response extends HttpServletResponseWrapper {
+    private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
+
+    Response(HttpServletResponse rsp) {
+      super(rsp);
+    }
+
+    private void status(int sc) {
+      if (sc == SC_UNAUTHORIZED) {
+        StringBuilder v = new StringBuilder();
+        v.append(BASIC);
+        v.append("realm=\"").append(REALM_NAME).append("\"");
+        setHeader(WWW_AUTHENTICATE, v.toString());
+      } else if (containsHeader(WWW_AUTHENTICATE)) {
+        setHeader(WWW_AUTHENTICATE, null);
+      }
+    }
+
+    @Override
+    public void sendError(int sc, String msg) throws IOException {
+      status(sc);
+      super.sendError(sc, msg);
+    }
+
+    @Override
+    public void sendError(int sc) throws IOException {
+      status(sc);
+      super.sendError(sc);
+    }
+
+    @Override
+    @Deprecated
+    public void setStatus(int sc, String sm) {
+      status(sc);
+      super.setStatus(sc, sm);
+    }
+
+    @Override
+    public void setStatus(int sc) {
+      status(sc);
+      super.setStatus(sc);
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java
new file mode 100644
index 0000000..ec193c9
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java
@@ -0,0 +1,44 @@
+// 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.httpd;
+
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class RequestMetrics {
+  final Counter1<Integer> errors;
+  final Counter1<Integer> successes;
+
+  @Inject
+  public RequestMetrics(MetricMaker metricMaker) {
+    errors = metricMaker.newCounter(
+        "http/server/error_count",
+        new Description("Rate of REST API error responses")
+          .setRate()
+          .setUnit("errors"),
+        Field.ofInteger("status", "HTTP status code"));
+    successes = metricMaker.newCounter(
+        "http/server/success_count",
+        new Description("Rate of REST API success responses")
+          .setRate()
+          .setUnit("successes"),
+        Field.ofInteger("status", "HTTP status code"));
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java
new file mode 100644
index 0000000..48b2a2f
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java
@@ -0,0 +1,108 @@
+// 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.httpd;
+
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.servlet.ServletModule;
+
+import java.io.IOException;
+
+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.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+
+@Singleton
+public class RequestMetricsFilter implements Filter {
+  public static Module module() {
+    return new ServletModule() {
+      @Override
+      protected void configureServlets() {
+        filter("/*").through(RequestMetricsFilter.class);
+      }
+    };
+  }
+
+  private final RequestMetrics metrics;
+
+  @Inject
+  RequestMetricsFilter(RequestMetrics metrics) {
+    this.metrics = metrics;
+  }
+
+  @Override
+  public void destroy() {
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response,
+      FilterChain chain) throws IOException, ServletException {
+    Response rsp = new Response((HttpServletResponse) response, metrics);
+
+    chain.doFilter(request, rsp);
+  }
+
+  @Override
+  public void init(FilterConfig cfg) throws ServletException {
+  }
+
+  private static class Response extends HttpServletResponseWrapper {
+    private final RequestMetrics metrics;
+
+    Response(HttpServletResponse response, RequestMetrics metrics) {
+      super(response);
+      this.metrics = metrics;
+    }
+
+    @Override
+    public void sendError(int sc, String msg) throws IOException {
+      status(sc);
+      super.sendError(sc, msg);
+    }
+
+    @Override
+    public void sendError(int sc) throws IOException {
+      status(sc);
+      super.sendError(sc);
+    }
+
+    @Override
+    @Deprecated
+    public void setStatus(int sc, String sm) {
+      status(sc);
+      super.setStatus(sc, sm);
+    }
+
+    @Override
+    public void setStatus(int sc) {
+      status(sc);
+      super.setStatus(sc);
+    }
+
+    private void status(int sc) {
+      if (sc >= SC_BAD_REQUEST) {
+        metrics.errors.increment(sc);
+      } else {
+        metrics.successes.increment(sc);
+      }
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java
index 92809c0..4cb8e92 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java
@@ -34,11 +34,20 @@
 
 /** Requires the connection to use SSL, redirects if not. */
 @Singleton
-class RequireSslFilter implements Filter {
-  static class Module extends ServletModule {
+public class RequireSslFilter implements Filter {
+  public static class Module extends ServletModule {
+    private final boolean wantSsl;
+
+    @Inject
+    Module(@Nullable @CanonicalWebUrl String canonicalUrl) {
+      this.wantSsl = canonicalUrl != null && canonicalUrl.startsWith("https:");
+    }
+
     @Override
     protected void configureServlets() {
-      filter("/*").through(RequireSslFilter.class);
+      if (wantSsl) {
+        filter("/*").through(RequireSslFilter.class);
+      }
     }
   }
 
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 4b3eca7..f3874be 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
@@ -22,7 +22,6 @@
 import com.google.gerrit.httpd.raw.HostPageServlet;
 import com.google.gerrit.httpd.raw.LegacyGerritServlet;
 import com.google.gerrit.httpd.raw.SshInfoServlet;
-import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.httpd.raw.ToolServlet;
 import com.google.gerrit.httpd.rpc.access.AccessRestApiServlet;
 import com.google.gerrit.httpd.rpc.account.AccountsRestApiServlet;
@@ -64,6 +63,7 @@
     bind(Key.get(CacheControlFilter.class)).in(SINGLETON);
 
     if (options.enableDefaultUi()) {
+      filter("/").through(XsrfCookieFilter.class);
       serve("/").with(HostPageServlet.class);
       serve("/Gerrit").with(LegacyGerritServlet.class);
       serve("/Gerrit/*").with(legacyGerritScreen());
@@ -103,8 +103,6 @@
     serveRegex("^/(?:a/)?projects/(.*)?$").with(ProjectsRestApiServlet.class);
 
     filter("/Documentation/").through(QueryDocumentationFilter.class);
-
-    install(new StaticModule());
   }
 
   private Key<HttpServlet> notFound() {
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 9e425e9..422a83d 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
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.registerInParentInjectors;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.httpd.auth.become.BecomeAnyAccountModule;
@@ -28,7 +27,6 @@
 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.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.git.AsyncReceiveCommits;
@@ -42,17 +40,14 @@
 
 public class WebModule extends LifecycleModule {
   private final AuthConfig authConfig;
-  private final boolean wantSSL;
   private final GitwebCgiConfig gitwebCgiConfig;
   private final GerritOptions options;
 
   @Inject
   WebModule(AuthConfig authConfig,
-      @CanonicalWebUrl @Nullable String canonicalUrl,
       GerritOptions options,
       GitwebCgiConfig gitwebCgiConfig) {
     this.authConfig = authConfig;
-    this.wantSSL = canonicalUrl != null && canonicalUrl.startsWith("https:");
     this.options = options;
     this.gitwebCgiConfig = gitwebCgiConfig;
   }
@@ -62,9 +57,6 @@
     bind(RequestScopePropagator.class).to(GuiceRequestScopePropagator.class);
     bind(HttpRequestContext.class);
 
-    if (wantSSL) {
-      install(new RequireSslFilter.Module());
-    }
     install(new RunAsFilter.Module());
 
     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 b2d32fc..327aaa3 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
@@ -21,18 +21,18 @@
 import com.google.gerrit.server.account.AuthResult;
 
 public interface WebSession {
-  public boolean isSignedIn();
-  public String getXGerritAuth();
-  public boolean isValidXGerritAuth(String keyIn);
-  public AccountExternalId.Key getLastLoginExternalId();
-  public CurrentUser getUser();
-  public void login(AuthResult res, boolean rememberMe);
+  boolean isSignedIn();
+  String getXGerritAuth();
+  boolean isValidXGerritAuth(String keyIn);
+  AccountExternalId.Key getLastLoginExternalId();
+  CurrentUser getUser();
+  void login(AuthResult res, boolean rememberMe);
 
   /** Set the user account for this current request only. */
-  public void setUserAccountId(Account.Id id);
-  public boolean isAccessPathOk(AccessPath path);
-  public void setAccessPathOk(AccessPath path, boolean ok);
+  void setUserAccountId(Account.Id id);
+  boolean isAccessPathOk(AccessPath path);
+  void setAccessPathOk(AccessPath path, boolean ok);
 
-  public void logout();
-  public String getSessionId();
+  void logout();
+  String getSessionId();
 }
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
new file mode 100644
index 0000000..c14d043
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java
@@ -0,0 +1,81 @@
+// 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.httpd;
+
+import com.google.gerrit.common.data.HostPageData;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.CurrentUser;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.io.IOException;
+
+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.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class XsrfCookieFilter implements Filter {
+  private final Provider<CurrentUser> user;
+  private final DynamicItem<WebSession> session;
+
+  @Inject
+  XsrfCookieFilter(
+      Provider<CurrentUser> user,
+      DynamicItem<WebSession> session) {
+    this.user = user;
+    this.session = session;
+  }
+
+  @Override
+  public void doFilter(ServletRequest req, ServletResponse rsp,
+      FilterChain chain) throws IOException, ServletException {
+    WebSession s = user.get().isIdentifiedUser() ? session.get() : null;
+    setXsrfTokenCookie(
+        (HttpServletRequest) req, (HttpServletResponse) rsp, s);
+    chain.doFilter(req, rsp);
+  }
+
+  private static void setXsrfTokenCookie(HttpServletRequest req,
+      HttpServletResponse rsp, WebSession session) {
+    String v = session != null ? session.getXGerritAuth() : "";
+    Cookie c = new Cookie(HostPageData.XSRF_COOKIE_NAME, v);
+    c.setPath("/");
+    c.setSecure(isSecure(req));
+    c.setMaxAge(session != null
+        ? -1 // Set the cookie for this browser session.
+        : 0); // Remove the cookie (expire immediately).
+    rsp.addCookie(c);
+  }
+
+  private static boolean isSecure(HttpServletRequest req) {
+    return req.isSecure() || "https".equals(req.getScheme());
+  }
+
+  @Override
+  public void init(FilterConfig config) {
+  }
+
+  @Override
+  public void destroy() {
+  }
+}
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
new file mode 100644
index 0000000..86bbf98
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java
@@ -0,0 +1,48 @@
+// 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.httpd.raw;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.launcher.GerritLauncher;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+class BowerComponentsServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  static Path getZipPath(Path buckOut) {
+    if (buckOut == null) {
+      return null;
+    }
+    return buckOut.resolve("gen")
+        .resolve("polygerrit-ui")
+        .resolve("polygerrit_components")
+        .resolve("polygerrit_components.bower_components.zip");
+  }
+
+  private final Path zip;
+
+  BowerComponentsServlet(Cache<Path, Resource> cache, Path buckOut) {
+    super(cache, true);
+    this.zip = getZipPath(buckOut);
+  }
+
+  @Override
+  protected Path getResourcePath(String pathInfo) throws IOException {
+    return GerritLauncher.getZipFileSystem(zip)
+        .getPath("bower_components/" + pathInfo);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java
new file mode 100644
index 0000000..0b4a02e
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java
@@ -0,0 +1,118 @@
+// 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.httpd.raw;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.escape.Escaper;
+import com.google.common.html.HtmlEscapers;
+import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gwtexpui.server.CacheHeaders;
+
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.PrintWriter;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Properties;
+
+import javax.servlet.http.HttpServletResponse;
+
+class BuckUtils {
+  private static final Logger log =
+      LoggerFactory.getLogger(BuckUtils.class);
+
+  static void build(Path root, Path gen, String target)
+      throws IOException, BuildFailureException {
+    log.info("buck build " + target);
+    Properties properties = loadBuckProperties(gen);
+    String buck = firstNonNull(properties.getProperty("buck"), "buck");
+    ProcessBuilder proc = new ProcessBuilder(buck, "build", target)
+        .directory(root.toFile())
+        .redirectErrorStream(true);
+    if (properties.containsKey("PATH")) {
+      proc.environment().put("PATH", properties.getProperty("PATH"));
+    }
+    long start = TimeUtil.nowMs();
+    Process rebuild = proc.start();
+    byte[] out;
+    try (InputStream in = rebuild.getInputStream()) {
+      out = ByteStreams.toByteArray(in);
+    } finally {
+      rebuild.getOutputStream().close();
+    }
+
+    int status;
+    try {
+      status = rebuild.waitFor();
+    } catch (InterruptedException e) {
+      throw new InterruptedIOException("interrupted waiting for " + buck);
+    }
+    if (status != 0) {
+      throw new BuildFailureException(out);
+    }
+
+    long time = TimeUtil.nowMs() - start;
+    log.info(String.format("UPDATED    %s in %.3fs", target, time / 1000.0));
+  }
+
+  private static Properties loadBuckProperties(Path gen) throws IOException {
+    Properties properties = new Properties();
+    Path p = gen.resolve(Paths.get("tools/buck/buck.properties"));
+    try (InputStream in = Files.newInputStream(p)) {
+      properties.load(in);
+    } catch (NoSuchFileException e) {
+      // Ignore; will be run from PATH, with a descriptive error if it fails.
+    }
+    return properties;
+  }
+
+  static void displayFailure(String rule, byte[] why, HttpServletResponse res)
+      throws IOException {
+    res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+    res.setContentType("text/html");
+    res.setCharacterEncoding(UTF_8.name());
+    CacheHeaders.setNotCacheable(res);
+
+    Escaper html = HtmlEscapers.htmlEscaper();
+    try (PrintWriter w = res.getWriter()) {
+      w.write("<html><title>BUILD FAILED</title><body>");
+      w.format("<h1>%s FAILED</h1>", html.escape(rule));
+      w.write("<pre>");
+      w.write(html.escape(RawParseUtils.decode(why)));
+      w.write("</pre>");
+      w.write("</body></html>");
+    }
+  }
+
+  static class BuildFailureException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    final byte[] why;
+
+    BuildFailureException(byte[] why) {
+      this.why = why;
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
index ab3728e..43c66db 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
@@ -25,13 +25,11 @@
 import com.google.gerrit.common.Version;
 import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.systemstatus.MessageOfTheDay;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.httpd.HtmlDomUtil;
-import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
@@ -67,7 +65,6 @@
 
 import javax.servlet.ServletContext;
 import javax.servlet.ServletException;
-import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -83,7 +80,6 @@
   private static final int DEFAULT_JS_LOAD_TIMEOUT = 5000;
 
   private final Provider<CurrentUser> currentUser;
-  private final DynamicItem<WebSession> session;
   private final DynamicSet<WebUiPlugin> plugins;
   private final DynamicSet<MessageOfTheDay> messages;
   private final HostPageData.Theme signedOutTheme;
@@ -101,7 +97,6 @@
   @Inject
   HostPageServlet(
       Provider<CurrentUser> cu,
-      DynamicItem<WebSession> w,
       SitePaths sp,
       ThemeFactory themeFactory,
       ServletContext servletContext,
@@ -113,7 +108,6 @@
       GetDiffPreferences diffPref)
       throws IOException, ServletException {
     currentUser = cu;
-    session = w;
     plugins = webUiPlugins;
     messages = motd;
     signedOutTheme = themeFactory.getSignedOutTheme();
@@ -193,7 +187,6 @@
     StringWriter w = new StringWriter();
     CurrentUser user = currentUser.get();
     if (user.isIdentifiedUser()) {
-      setXGerritAuthCookie(req, rsp, session.get());
       w.write(HPD_ID + ".accountDiffPref=");
       json(getDiffPreferences(user.asIdentifiedUser()), w);
       w.write(";");
@@ -202,7 +195,6 @@
       json(signedInTheme, w);
       w.write(";");
     } else {
-      setXGerritAuthCookie(req, rsp, null);
       w.write(HPD_ID + ".theme=");
       json(signedOutTheme, w);
       w.write(";");
@@ -229,23 +221,6 @@
     }
   }
 
-  private static void setXGerritAuthCookie(HttpServletRequest req,
-      HttpServletResponse rsp, WebSession session) {
-    String v = session != null ? session.getXGerritAuth() : "";
-    Cookie c = new Cookie(HostPageData.XSRF_COOKIE_NAME, v);
-    c.setPath("/");
-    c.setHttpOnly(false);
-    c.setSecure(isSecure(req));
-    c.setMaxAge(session != null
-        ? -1 // Set the cookie for this browser session.
-        : 0); // Remove the cookie (expire immediately).
-    rsp.addCookie(c);
-  }
-
-  private static boolean isSecure(HttpServletRequest req) {
-    return req.isSecure() || "https".equals(req.getScheme());
-  }
-
   private DiffPreferencesInfo getDiffPreferences(IdentifiedUser user) {
     try {
       return getDiff.apply(new AccountResource(user));
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiIndexServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiIndexServlet.java
new file mode 100644
index 0000000..3b225c9
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiIndexServlet.java
@@ -0,0 +1,35 @@
+// 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.httpd.raw;
+
+import com.google.common.cache.Cache;
+
+import java.nio.file.Path;
+
+class PolyGerritUiIndexServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final Path index;
+
+  PolyGerritUiIndexServlet(Cache<Path, Resource> cache, Path ui) {
+    super(cache, true);
+    index = ui.resolve("index.html");
+  }
+
+  @Override
+  protected Path getResourcePath(String pathInfo) {
+    return index;
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
new file mode 100644
index 0000000..4ca8b1c
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
@@ -0,0 +1,35 @@
+// 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.httpd.raw;
+
+import com.google.common.cache.Cache;
+
+import java.nio.file.Path;
+
+class PolyGerritUiServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final Path ui;
+
+  PolyGerritUiServlet(Cache<Path, Resource> cache, Path ui) {
+    super(cache, true);
+    this.ui = ui;
+  }
+
+  @Override
+  protected Path getResourcePath(String pathInfo) {
+    return ui.resolve(pathInfo);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RebuildBowerComponentsFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RebuildBowerComponentsFilter.java
new file mode 100644
index 0000000..38eaa0a
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RebuildBowerComponentsFilter.java
@@ -0,0 +1,74 @@
+// 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.httpd.raw;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.httpd.raw.BuckUtils.BuildFailureException;
+import com.google.gerrit.launcher.GerritLauncher;
+import com.google.inject.Singleton;
+
+import java.io.IOException;
+import java.nio.file.Files;
+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.HttpServletResponse;
+
+@Singleton
+class RebuildBowerComponentsFilter implements Filter {
+  private static final String TARGET = "//polygerrit-ui:polygerrit_components";
+
+  private final Path gen;
+  private final Path root;
+  private final Path zip;
+
+  RebuildBowerComponentsFilter(Path buckOut) {
+    gen = buckOut.resolve("gen");
+    root = buckOut.getParent();
+    zip = BowerComponentsServlet.getZipPath(buckOut);
+  }
+
+  @Override
+  public synchronized void doFilter(ServletRequest sreq, ServletResponse sres,
+      FilterChain chain) throws IOException, ServletException {
+    HttpServletResponse res = (HttpServletResponse) sres;
+    try {
+      BuckUtils.build(root, gen, TARGET);
+    } catch (BuildFailureException e) {
+      BuckUtils.displayFailure(TARGET, e.why, res);
+      return;
+    }
+    if (!Files.exists(zip)) {
+      String msg = "`buck build` did not produce " + zip.toAbsolutePath();
+      BuckUtils.displayFailure(TARGET, msg.getBytes(UTF_8), res);
+    }
+    GerritLauncher.reloadZipFileSystem(zip);
+    chain.doFilter(sreq, sres);
+  }
+
+  @Override
+  public void init(FilterConfig config) throws ServletException {
+  }
+
+  @Override
+  public void destroy() {
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
index a5bc6c6..1984cbb 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
@@ -14,33 +14,16 @@
 
 package com.google.gerrit.httpd.raw;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.escape.Escaper;
-import com.google.common.html.HtmlEscapers;
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.httpd.raw.BuckUtils.BuildFailureException;
 import com.google.gwtexpui.linker.server.UserAgentRule;
-import com.google.gwtexpui.server.CacheHeaders;
-
-import org.eclipse.jgit.util.RawParseUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.InterruptedIOException;
-import java.io.PrintWriter;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.Enumeration;
 import java.util.HashSet;
-import java.util.Properties;
 import java.util.Set;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
@@ -55,9 +38,6 @@
 import javax.servlet.http.HttpServletResponse;
 
 class RecompileGwtUiFilter implements Filter {
-  private static final Logger log =
-      LoggerFactory.getLogger(RecompileGwtUiFilter.class);
-
   private final boolean gwtuiRecompile =
       System.getProperty("gerrit.disable-gwtui-recompile") == null;
   private final UserAgentRule rule = new UserAgentRule();
@@ -92,9 +72,9 @@
 
       synchronized (this) {
         try {
-          build(root, gen, rule);
+          BuckUtils.build(root, gen, rule);
         } catch (BuildFailureException e) {
-          displayFailure(rule, e.why, (HttpServletResponse) res);
+          BuckUtils.displayFailure(rule, e.why, (HttpServletResponse) res);
           return;
         }
 
@@ -109,24 +89,6 @@
     chain.doFilter(request, res);
   }
 
-  private void displayFailure(String rule, byte[] why, HttpServletResponse res)
-      throws IOException {
-    res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-    res.setContentType("text/html");
-    res.setCharacterEncoding(UTF_8.name());
-    CacheHeaders.setNotCacheable(res);
-
-    Escaper html = HtmlEscapers.htmlEscaper();
-    try (PrintWriter w = res.getWriter()) {
-      w.write("<html><title>BUILD FAILED</title><body>");
-      w.format("<h1>%s FAILED</h1>", html.escape(rule));
-      w.write("<pre>");
-      w.write(html.escape(RawParseUtils.decode(why)));
-      w.write("</pre>");
-      w.write("</body></html>");
-    }
-  }
-
   @Override
   public void init(FilterConfig config) {
   }
@@ -166,59 +128,6 @@
     }
   }
 
-  private static void build(Path root, Path gen, String target)
-      throws IOException, BuildFailureException {
-    log.info("buck build " + target);
-    Properties properties = loadBuckProperties(gen);
-    String buck = MoreObjects.firstNonNull(properties.getProperty("buck"), "buck");
-    ProcessBuilder proc = new ProcessBuilder(buck, "build", target)
-        .directory(root.toFile())
-        .redirectErrorStream(true);
-    if (properties.containsKey("PATH")) {
-      proc.environment().put("PATH", properties.getProperty("PATH"));
-    }
-    long start = TimeUtil.nowMs();
-    Process rebuild = proc.start();
-    byte[] out;
-    try (InputStream in = rebuild.getInputStream()) {
-      out = ByteStreams.toByteArray(in);
-    } finally {
-      rebuild.getOutputStream().close();
-    }
-
-    int status;
-    try {
-      status = rebuild.waitFor();
-    } catch (InterruptedException e) {
-      throw new InterruptedIOException("interrupted waiting for " + buck);
-    }
-    if (status != 0) {
-      throw new BuildFailureException(out);
-    }
-
-    long time = TimeUtil.nowMs() - start;
-    log.info(String.format("UPDATED    %s in %.3fs", target, time / 1000.0));
-  }
-
-  private static Properties loadBuckProperties(Path gen)
-      throws FileNotFoundException, IOException {
-    Properties properties = new Properties();
-    try (InputStream in = new FileInputStream(
-        gen.resolve(Paths.get("tools/buck/buck.properties")).toFile())) {
-      properties.load(in);
-    }
-    return properties;
-  }
-
-  @SuppressWarnings("serial")
-  private static class BuildFailureException extends Exception {
-    final byte[] why;
-
-    BuildFailureException(byte[] why) {
-      this.why = why;
-    }
-  }
-
   private static void mkdir(File dir) throws IOException {
     if (!dir.isDirectory()) {
       mkdir(dir.getParentFile());
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 86916c0..d9aab24 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
@@ -118,8 +118,9 @@
    *
    * @param pathInfo result of {@link HttpServletRequest#getPathInfo()}.
    * @return path where static content can be found.
+   * @throws IOException if an error occurred resolving the resource.
    */
-  protected abstract Path getResourcePath(String pathInfo);
+  protected abstract Path getResourcePath(String pathInfo) throws IOException;
 
   protected FileTime getLastModifiedTime(Path p) throws IOException {
     return Files.getLastModifiedTime(p);
@@ -205,7 +206,7 @@
     try {
       Path p = getResourcePath(name);
       return cache.get(p, newLoader(p));
-    } catch (ExecutionException e) {
+    } catch (ExecutionException | IOException e) {
       log.warn(String.format("Cannot load static resource %s", name), e);
       return null;
     }
@@ -292,7 +293,7 @@
     };
   }
 
-  static class Resource {
+  public static class Resource {
     static final Resource NOT_FOUND =
         new Resource(FileTime.fromMillis(0), "", new byte[] {});
 
@@ -321,7 +322,7 @@
     }
   }
 
-  static class Weigher
+  public static class Weigher
       implements com.google.common.cache.Weigher<Path, Resource> {
     @Override
     public int weigh(Path p, Resource r) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
index cf99d3c..6365306 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
@@ -51,15 +51,6 @@
 
   @Override
   protected Path getResourcePath(String pathInfo) {
-    Path p = staticBase.resolve(pathInfo);
-    try {
-      p = p.toRealPath().normalize();
-      if (!p.startsWith(staticBase)) {
-        return null;
-      }
-      return p;
-    } catch (IOException e) {
-      return null;
-    }
+    return staticBase.resolve(pathInfo);
   }
 }
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 e530710f..3ae36ff 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
@@ -14,15 +14,20 @@
 
 package com.google.gerrit.httpd.raw;
 
+import static com.google.common.base.Preconditions.checkArgument;
 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.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.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
 import com.google.inject.Key;
 import com.google.inject.Provides;
 import com.google.inject.ProvisionException;
@@ -49,35 +54,46 @@
   private static final Logger log =
       LoggerFactory.getLogger(StaticModule.class);
 
+  public static final String CACHE = "static_content";
+
+  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/*");
+
   private static final String DOC_SERVLET = "DocServlet";
   private static final String FAVICON_SERVLET = "FaviconServlet";
   private static final String GWT_UI_SERVLET = "GwtUiServlet";
   private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
 
-  static final String CACHE = "static_content";
+  private final GerritOptions options;
+  private Paths paths;
 
-  private final FileSystem warFs;
-  private final Path buckOut;
-  private final Path unpackedWar;
+  @Inject
+  public StaticModule(GerritOptions options) {
+    this.options = options;
+  }
 
-  public StaticModule() {
-    warFs = getDistributionArchive();
-    if (warFs == null) {
-      buckOut = getDeveloperBuckOut();
-      unpackedWar = makeWarTempDir();
-    } else {
-      buckOut = null;
-      unpackedWar = null;
+  private Paths getPaths() {
+    if (paths == null) {
+      paths = new Paths();
     }
+    return paths;
   }
 
   @Override
   protected void configureServlets() {
     serveRegex("^/Documentation/(.+)$").with(named(DOC_SERVLET));
     serve("/static/*").with(SiteStaticDirectoryServlet.class);
-    serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET));
-    serve("/favicon.ico").with(named(FAVICON_SERVLET));
-    serveGwtUi();
     install(new CacheModule() {
       @Override
       protected void configure() {
@@ -86,13 +102,12 @@
             .weigher(ResourceServlet.Weigher.class);
       }
     });
-  }
-
-  private void serveGwtUi() {
-    serveRegex("^/gerrit_ui/(?!rpc/)(.*)$")
-        .with(Key.get(HttpServlet.class, Names.named(GWT_UI_SERVLET)));
-    if (warFs == null) {
-      filter("/").through(new RecompileGwtUiFilter(buckOut, unpackedWar));
+    if (options.enablePolyGerrit()) {
+      install(new CoreStaticModule());
+      install(new PolyGerritUiModule());
+    } else if (options.enableDefaultUi()) {
+      install(new CoreStaticModule());
+      install(new GwtUiModule());
     }
   }
 
@@ -100,8 +115,9 @@
   @Singleton
   @Named(DOC_SERVLET)
   HttpServlet getDocServlet(@Named(CACHE) Cache<Path, Resource> cache) {
-    if (warFs != null) {
-      return new WarDocServlet(cache, warFs);
+    Paths p = getPaths();
+    if (p.warFs != null) {
+      return new WarDocServlet(cache, p.warFs);
     } else {
       return new HttpServlet() {
         private static final long serialVersionUID = 1L;
@@ -115,107 +131,222 @@
     }
   }
 
-  @Provides
-  @Singleton
-  @Named(GWT_UI_SERVLET)
-  HttpServlet getGwtUiServlet(@Named(CACHE) Cache<Path, Resource> cache)
-      throws IOException {
-    if (warFs != null) {
-      return new WarGwtUiServlet(cache, warFs);
-    } else {
-      return new DeveloperGwtUiServlet(cache, unpackedWar);
+  private class CoreStaticModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET));
+      serve("/favicon.ico").with(named(FAVICON_SERVLET));
     }
-  }
 
-  @Provides
-  @Singleton
-  @Named(ROBOTS_TXT_SERVLET)
-  HttpServlet getRobotsTxtServlet(@GerritServerConfig Config cfg,
-      SitePaths sitePaths, @Named(CACHE) Cache<Path, Resource> cache) {
-    Path configPath = sitePaths.resolve(
-        cfg.getString("httpd", null, "robotsFile"));
-    if (configPath != null) {
-      if (exists(configPath) && isReadable(configPath)) {
-        return new SingleFileServlet(cache, configPath, true);
+    @Provides
+    @Singleton
+    @Named(ROBOTS_TXT_SERVLET)
+    HttpServlet getRobotsTxtServlet(@GerritServerConfig Config cfg,
+        SitePaths sitePaths, @Named(CACHE) Cache<Path, Resource> cache) {
+      Path configPath = sitePaths.resolve(
+          cfg.getString("httpd", null, "robotsFile"));
+      if (configPath != null) {
+        if (exists(configPath) && isReadable(configPath)) {
+          return new SingleFileServlet(cache, configPath, true);
+        } else {
+          log.warn("Cannot read httpd.robotsFile, using default");
+        }
+      }
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new SingleFileServlet(
+            cache, p.warFs.getPath("/robots.txt"), false);
       } else {
-        log.warn("Cannot read httpd.robotsFile, using default");
+        return new SingleFileServlet(
+            cache, webappSourcePath("robots.txt"), true);
       }
     }
-    if (warFs != null) {
-      return new SingleFileServlet(cache, warFs.getPath("/robots.txt"), false);
-    } else {
-      return new SingleFileServlet(cache, webappSourcePath("robots.txt"), true);
-    }
-  }
 
-  @Provides
-  @Singleton
-  @Named(FAVICON_SERVLET)
-  HttpServlet getFaviconServlet(@Named(CACHE) Cache<Path, Resource> cache) {
-    if (warFs != null) {
-      return new SingleFileServlet(cache, warFs.getPath("/favicon.ico"), false);
-    } else {
-      return new SingleFileServlet(
-          cache, webappSourcePath("favicon.ico"), true);
-    }
-  }
-
-  private Path webappSourcePath(String name) {
-    return buckOut.resolveSibling("gerrit-war").resolve("src").resolve("main")
-        .resolve("webapp").resolve(name);
-  }
-
-  private static Key<HttpServlet> named(String name) {
-    return Key.get(HttpServlet.class, Names.named(name));
-  }
-
-  private static FileSystem getDistributionArchive() {
-    try {
-      return GerritLauncher.getDistributionArchiveFileSystem();
-    } catch (IOException e) {
-      if ((e instanceof FileNotFoundException)
-          && GerritLauncher.NOT_ARCHIVED.equals(e.getMessage())) {
-        return null;
+    @Provides
+    @Singleton
+    @Named(FAVICON_SERVLET)
+    HttpServlet getFaviconServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new SingleFileServlet(
+            cache, p.warFs.getPath("/favicon.ico"), false);
       } else {
+        return new SingleFileServlet(
+            cache, webappSourcePath("favicon.ico"), true);
+      }
+    }
+
+    private Path webappSourcePath(String name) {
+      return getPaths().buckOut.resolveSibling("gerrit-war").resolve("src")
+          .resolve("main").resolve("webapp").resolve(name);
+    }
+  }
+
+  private class GwtUiModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      serveRegex("^/gerrit_ui/(?!rpc/)(.*)$")
+          .with(Key.get(HttpServlet.class, Names.named(GWT_UI_SERVLET)));
+      Paths p = getPaths();
+      if (p.warFs == null && p.buckOut != null) {
+        filter("/").through(new RecompileGwtUiFilter(p.buckOut, p.unpackedWar));
+      }
+    }
+
+    @Provides
+    @Singleton
+    @Named(GWT_UI_SERVLET)
+    HttpServlet getGwtUiServlet(@Named(CACHE) Cache<Path, Resource> cache)
+        throws IOException {
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new WarGwtUiServlet(cache, p.warFs);
+      } else {
+        return new DeveloperGwtUiServlet(cache, p.unpackedWar);
+      }
+    }
+  }
+
+  private class PolyGerritUiModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      Path buckOut = getPaths().buckOut;
+      if (buckOut != null) {
+        RebuildBowerComponentsFilter rebuildFilter =
+            new RebuildBowerComponentsFilter(buckOut);
+        for (String p : POLYGERRIT_INDEX_PATHS) {
+          // Rebuilding bower_components once per load on the index request,
+          // is sufficient, since it will finish building before attempting to
+          // access any bower_components resources. Plus it saves contention and
+          // extraneous buck builds.
+          filter(p).through(rebuildFilter);
+        }
+        serve("/bower_components/*").with(BowerComponentsServlet.class);
+      } else {
+        // In the war case, bower_components are either inlined by vulcanize, or
+        // live under /polygerrit_ui in the war file, so we don't need a
+        // separate servlet.
+      }
+
+      for (String p : POLYGERRIT_INDEX_PATHS) {
+        filter(p).through(XsrfCookieFilter.class);
+        serve(p).with(PolyGerritUiIndexServlet.class);
+      }
+      serve("/*").with(PolyGerritUiServlet.class);
+    }
+
+    @Provides
+    @Singleton
+    PolyGerritUiIndexServlet getPolyGerritUiIndexServlet(
+        @Named(CACHE) Cache<Path, Resource> cache) {
+      return new PolyGerritUiIndexServlet(cache, polyGerritBasePath());
+    }
+
+    @Provides
+    @Singleton
+    PolyGerritUiServlet getPolyGerritUiServlet(
+        @Named(CACHE) Cache<Path, Resource> cache) {
+      return new PolyGerritUiServlet(cache, polyGerritBasePath());
+    }
+
+    @Provides
+    @Singleton
+    BowerComponentsServlet getBowerComponentsServlet(
+        @Named(CACHE) Cache<Path, Resource> cache) {
+      return new BowerComponentsServlet(cache, getPaths().buckOut);
+    }
+
+    private Path polyGerritBasePath() {
+      Paths p = getPaths();
+      boolean forceDev = options.forcePolyGerritDev();
+      if (forceDev) {
+        checkArgument(p.buckOut != null,
+            "no buck-out directory found for PolyGerrit developer mode");
+      }
+      return forceDev || p.warFs == null
+          ? p.buckOut.getParent().resolve("polygerrit-ui").resolve("app")
+          : p.warFs.getPath("/polygerrit_ui");
+    }
+  }
+
+  private class Paths {
+    private final FileSystem warFs;
+    private final Path buckOut;
+    private final Path unpackedWar;
+
+    private Paths() {
+      try {
+        warFs = getDistributionArchive();
+        if (warFs == null) {
+          buckOut = getDeveloperBuckOut();
+          unpackedWar = makeWarTempDir();
+        } else if (options.forcePolyGerritDev()) {
+          buckOut = getDeveloperBuckOut();
+          unpackedWar = null;
+        } else {
+          buckOut = null;
+          unpackedWar = null;
+        }
+      } catch (IOException e) {
+        throw new ProvisionException(
+            "Error initializing static content paths", e);
+      }
+    }
+
+    private FileSystem getDistributionArchive() throws IOException {
+      File war;
+      try {
+        war = GerritLauncher.getDistributionArchive();
+      } catch (IOException e) {
+        if ((e instanceof FileNotFoundException)
+            && GerritLauncher.NOT_ARCHIVED.equals(e.getMessage())) {
+          return null;
+        } else {
+          ProvisionException pe =
+              new ProvisionException("Error reading gerrit.war");
+          pe.initCause(e);
+          throw pe;
+        }
+      }
+      return GerritLauncher.getZipFileSystem(war.toPath());
+    }
+
+    private Path getDeveloperBuckOut() {
+      try {
+        return GerritLauncher.getDeveloperBuckOut();
+      } catch (FileNotFoundException e) {
+        return null;
+      }
+    }
+
+    private Path makeWarTempDir() {
+      // Obtain our local temporary directory, but it comes back as a file
+      // so we have to switch it to be a directory post creation.
+      //
+      try {
+        File dstwar = GerritLauncher.createTempFile("gerrit_", "war");
+        if (!dstwar.delete() || !dstwar.mkdir()) {
+          throw new IOException("Cannot mkdir " + dstwar.getAbsolutePath());
+        }
+
+        // Jetty normally refuses to serve out of a symlinked directory, as
+        // a security feature. Try to resolve out any symlinks in the path.
+        //
+        try {
+          return dstwar.getCanonicalFile().toPath();
+        } catch (IOException e) {
+          return dstwar.getAbsoluteFile().toPath();
+        }
+      } catch (IOException e) {
         ProvisionException pe =
-            new ProvisionException("Error reading gerrit.war");
+            new ProvisionException("Cannot create war tempdir");
         pe.initCause(e);
         throw pe;
       }
     }
   }
 
-  private static Path getDeveloperBuckOut() {
-    try {
-      return GerritLauncher.getDeveloperBuckOut();
-    } catch (FileNotFoundException e) {
-      return null;
-    }
-  }
-
-  private static Path makeWarTempDir() {
-    // Obtain our local temporary directory, but it comes back as a file
-    // so we have to switch it to be a directory post creation.
-    //
-    try {
-      File dstwar = GerritLauncher.createTempFile("gerrit_", "war");
-      if (!dstwar.delete() || !dstwar.mkdir()) {
-        throw new IOException("Cannot mkdir " + dstwar.getAbsolutePath());
-      }
-
-      // Jetty normally refuses to serve out of a symlinked directory, as
-      // a security feature. Try to resolve out any symlinks in the path.
-      //
-      try {
-        return dstwar.getCanonicalFile().toPath();
-      } catch (IOException e) {
-        return dstwar.getAbsoluteFile().toPath();
-      }
-    } catch (IOException e) {
-      ProvisionException pe =
-          new ProvisionException("Cannot create war tempdir");
-      pe.initCause(e);
-      throw pe;
-    }
+  private static Key<HttpServlet> named(String name) {
+    return Key.get(HttpServlet.class, Names.named(name));
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceKey.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceKey.java
index ef5a1df..182ee7e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceKey.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/ResourceKey.java
@@ -15,5 +15,5 @@
 package com.google.gerrit.httpd.resources;
 
 public interface ResourceKey {
-  public int weigh();
+  int weigh();
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
new file mode 100644
index 0000000..2a01b77
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
@@ -0,0 +1,87 @@
+// 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.httpd.restapi;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.httpd.restapi.RestApiServlet.ViewData;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram1;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class RestApiMetrics {
+  private static final String[] PKGS = {
+    "com.google.gerrit.server.",
+    "com.google.gerrit.",
+  };
+
+  final Counter1<String> count;
+  final Counter2<String, Integer> errorCount;
+  final Timer1<String> serverLatency;
+  final Histogram1<String> responseBytes;
+
+  @Inject
+  RestApiMetrics(MetricMaker metrics) {
+    Field<String> view = Field.ofString("view", "view implementation class");
+    count = metrics.newCounter(
+        "http/server/rest_api/count",
+        new Description("REST API calls by view")
+          .setRate(),
+        view);
+
+    errorCount = metrics.newCounter(
+        "http/server/rest_api/error_count",
+        new Description("REST API calls by view")
+          .setRate(),
+        view,
+        Field.ofInteger("error_code", "HTTP status code"));
+
+    serverLatency = metrics.newTimer(
+        "http/server/rest_api/server_latency",
+        new Description("REST API call latency by view")
+          .setCumulative()
+          .setUnit(Units.MILLISECONDS),
+        view);
+
+    responseBytes = metrics.newHistogram(
+        "http/server/rest_api/response_bytes",
+        new Description("Size of response on network (may be gzip compressed)")
+          .setCumulative()
+          .setUnit(Units.BYTES),
+        view);
+  }
+
+  String view(ViewData viewData) {
+    String impl = viewData.view.getClass().getName().replace('$', '.');
+    for (String p : PKGS) {
+      if (impl.startsWith(p)) {
+        impl = impl.substring(p.length());
+        break;
+      }
+    }
+    if (!Strings.isNullOrEmpty(viewData.pluginName)
+        && !"gerrit".equals(viewData.pluginName)) {
+      impl = viewData.pluginName + '-' + impl;
+    }
+    return impl;
+  }
+}
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 7347cd9..f2ca49d 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
@@ -43,6 +43,7 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Sets;
 import com.google.common.io.BaseEncoding;
+import com.google.common.io.CountingOutputStream;
 import com.google.common.math.IntMath;
 import com.google.common.net.HttpHeaders;
 import com.google.gerrit.audit.AuditService;
@@ -125,6 +126,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 import java.util.regex.Pattern;
 import java.util.zip.GZIPOutputStream;
 
@@ -164,16 +166,19 @@
     final DynamicItem<WebSession> webSession;
     final Provider<ParameterParser> paramParser;
     final AuditService auditService;
+    final RestApiMetrics metrics;
 
     @Inject
     Globals(Provider<CurrentUser> currentUser,
         DynamicItem<WebSession> webSession,
         Provider<ParameterParser> paramParser,
-        AuditService auditService) {
+        AuditService auditService,
+        RestApiMetrics metrics) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
       this.auditService = auditService;
+      this.metrics = metrics;
     }
   }
 
@@ -197,10 +202,12 @@
   @Override
   protected final void service(HttpServletRequest req, HttpServletResponse res)
       throws ServletException, IOException {
+    final long startNanos = System.nanoTime();
     long auditStartTs = TimeUtil.nowMs();
     res.setHeader("Content-Disposition", "attachment");
     res.setHeader("X-Content-Type-Options", "nosniff");
     int status = SC_OK;
+    long responseBytes = -1;
     Object result = null;
     Multimap<String, String> params = LinkedHashMultimap.create();
     Object inputRequestBody = null;
@@ -348,47 +355,62 @@
       if (result != Response.none()) {
         result = Response.unwrap(result);
         if (result instanceof BinaryResult) {
-          replyBinaryResult(req, res, (BinaryResult) result);
+          responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
         } else {
-          replyJson(req, res, config, result);
+          responseBytes = replyJson(req, res, config, result);
         }
       }
     } catch (MalformedJsonException e) {
-      replyError(req, res, status = SC_BAD_REQUEST,
+      responseBytes = replyError(req, res, status = SC_BAD_REQUEST,
           "Invalid " + JSON_TYPE + " in request", e);
     } catch (JsonParseException e) {
-      replyError(req, res, status = SC_BAD_REQUEST,
+      responseBytes = replyError(req, res, status = SC_BAD_REQUEST,
           "Invalid " + JSON_TYPE + " in request", e);
     } catch (BadRequestException e) {
-      replyError(req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"),
-          e.caching(), e);
+      responseBytes = replyError(req, res, status = SC_BAD_REQUEST,
+          messageOr(e, "Bad Request"), e.caching(), e);
     } catch (AuthException e) {
-      replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"),
-          e.caching(), e);
+      responseBytes = replyError(req, res, status = SC_FORBIDDEN,
+          messageOr(e, "Forbidden"), e.caching(), e);
     } catch (AmbiguousViewException e) {
-      replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
+      responseBytes = replyError(req, res, status = SC_NOT_FOUND,
+          messageOr(e, "Ambiguous"), e);
     } catch (ResourceNotFoundException e) {
-      replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"),
-          e.caching(), e);
+      responseBytes = replyError(req, res, status = SC_NOT_FOUND,
+          messageOr(e, "Not Found"), e.caching(), e);
     } catch (MethodNotAllowedException e) {
-      replyError(req, res, status = SC_METHOD_NOT_ALLOWED,
+      responseBytes = replyError(req, res, status = SC_METHOD_NOT_ALLOWED,
           messageOr(e, "Method Not Allowed"), e.caching(), e);
     } catch (ResourceConflictException e) {
-      replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"),
-          e.caching(), e);
+      responseBytes = replyError(req, res, status = SC_CONFLICT,
+          messageOr(e, "Conflict"), e.caching(), e);
     } catch (PreconditionFailedException e) {
-      replyError(req, res, status = SC_PRECONDITION_FAILED,
+      responseBytes = replyError(req, res, status = SC_PRECONDITION_FAILED,
           messageOr(e, "Precondition Failed"), e.caching(), e);
     } catch (UnprocessableEntityException e) {
-      replyError(req, res, status = 422, messageOr(e, "Unprocessable Entity"),
-          e.caching(), e);
+      responseBytes = replyError(req, res, status = 422,
+          messageOr(e, "Unprocessable Entity"), e.caching(), e);
     } catch (NotImplementedException e) {
-      replyError(req, res, status = SC_NOT_IMPLEMENTED,
+      responseBytes = replyError(req, res, status = SC_NOT_IMPLEMENTED,
           messageOr(e, "Not Implemented"), e);
     } catch (Exception e) {
       status = SC_INTERNAL_SERVER_ERROR;
-      handleException(e, req, res);
+      responseBytes = handleException(e, req, res);
     } finally {
+      String metric = viewData != null && viewData.view != null
+          ? globals.metrics.view(viewData)
+          : "_unknown";
+      globals.metrics.count.increment(metric);
+      if (status >= SC_BAD_REQUEST) {
+        globals.metrics.errorCount.increment(metric, status);
+      }
+      if (responseBytes != -1) {
+        globals.metrics.responseBytes.record(metric, responseBytes);
+      }
+      globals.metrics.serverLatency.record(
+          metric,
+          System.nanoTime() - startNanos,
+          TimeUnit.NANOSECONDS);
       globals.auditService.dispatch(new ExtendedHttpAuditEvent(globals.webSession.get()
           .getSessionId(), globals.currentUser.get(), req,
           auditStartTs, params, inputRequestBody, status,
@@ -651,7 +673,7 @@
     throw new InstantiationException("Cannot make " + type);
   }
 
-  public static void replyJson(@Nullable HttpServletRequest req,
+  public static long replyJson(@Nullable HttpServletRequest req,
       HttpServletResponse res,
       Multimap<String, String> config,
       Object result)
@@ -667,7 +689,7 @@
     }
     w.write('\n');
     w.flush();
-    replyBinaryResult(req, res, asBinaryResult(buf)
+    return replyBinaryResult(req, res, asBinaryResult(buf)
       .setContentType(JSON_TYPE)
       .setCharacterEncoding(UTF_8));
   }
@@ -736,7 +758,7 @@
   }
 
   @SuppressWarnings("resource")
-  static void replyBinaryResult(
+  static long replyBinaryResult(
       @Nullable HttpServletRequest req,
       HttpServletResponse res,
       BinaryResult bin) throws IOException {
@@ -767,10 +789,13 @@
       }
 
       if (req == null || !"HEAD".equals(req.getMethod())) {
-        try (OutputStream dst = res.getOutputStream()) {
+        try (CountingOutputStream dst =
+            new CountingOutputStream(res.getOutputStream())) {
           bin.writeTo(dst);
+          return dst.getCount();
         }
       }
+      return 0;
     } finally {
       appResult.close();
     }
@@ -977,7 +1002,7 @@
         viewData.pluginName, viewData.view.getClass());
   }
 
-  private static void handleException(Throwable err, HttpServletRequest req,
+  private static long handleException(Throwable err, HttpServletRequest req,
       HttpServletResponse res) throws IOException {
     String uri = req.getRequestURI();
     if (!Strings.isNullOrEmpty(req.getQueryString())) {
@@ -987,16 +1012,17 @@
 
     if (!res.isCommitted()) {
       res.reset();
-      replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
+      return replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
     }
+    return 0;
   }
 
-  public static void replyError(HttpServletRequest req, HttpServletResponse res,
+  public static long replyError(HttpServletRequest req, HttpServletResponse res,
       int statusCode, String msg, @Nullable Throwable err) throws IOException {
-    replyError(req, res, statusCode, msg, CacheControl.NONE, err);
+    return replyError(req, res, statusCode, msg, CacheControl.NONE, err);
   }
 
-  public static void replyError(HttpServletRequest req,
+  public static long replyError(HttpServletRequest req,
       HttpServletResponse res, int statusCode, String msg,
       CacheControl c, @Nullable Throwable err) throws IOException {
     if (err != null) {
@@ -1004,18 +1030,18 @@
     }
     configureCaching(req, res, null, null, c);
     res.setStatus(statusCode);
-    replyText(req, res, msg);
+    return replyText(req, res, msg);
   }
 
-  static void replyText(@Nullable HttpServletRequest req,
+  static long replyText(@Nullable HttpServletRequest req,
       HttpServletResponse res, String text) throws IOException {
     if ((req == null || isGetOrHead(req)) && isMaybeHTML(text)) {
-      replyJson(req, res, ImmutableMultimap.of("pp", "0"), new JsonPrimitive(text));
+      return replyJson(req, res, ImmutableMultimap.of("pp", "0"), new JsonPrimitive(text));
     } else {
       if (!text.endsWith("\n")) {
         text += "\n";
       }
-      replyBinaryResult(req, res,
+      return replyBinaryResult(req, res,
           BinaryResult.create(text).setContentType("text/plain"));
     }
   }
@@ -1099,7 +1125,7 @@
     }
   }
 
-  private static class ViewData {
+  static class ViewData {
     String pluginName;
     RestView<RestResource> view;
 
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 e0d63c8..3c1c12d 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
@@ -153,7 +153,7 @@
         }
 
         Account account = user.get().getAccount();
-        hooks.doClaSignupHook(account, ca);
+        hooks.doClaSignupHook(account, ca.getName());
 
         final AccountGroupMember.Key key =
             new AccountGroupMember.Key(account.getId(), group.getId());
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
index e877d1e..4496fc1 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -52,6 +53,7 @@
   }
 
   private final ChangeHooks hooks;
+  private final GitReferenceUpdated gitRefUpdated;
   private final IdentifiedUser user;
   private final ProjectAccessFactory.Factory projectAccessFactory;
   private final ProjectCache projectCache;
@@ -63,7 +65,9 @@
       MetaDataUpdate.User metaDataUpdateFactory,
       AllProjectsNameProvider allProjects,
       Provider<SetParent> setParent,
-      ChangeHooks hooks, IdentifiedUser user,
+      ChangeHooks hooks,
+      GitReferenceUpdated gitRefUpdated,
+      IdentifiedUser user,
       @Assisted("projectName") Project.NameKey projectName,
       @Nullable @Assisted ObjectId base,
       @Assisted List<AccessSection> sectionList,
@@ -75,6 +79,7 @@
     this.projectAccessFactory = projectAccessFactory;
     this.projectCache = projectCache;
     this.hooks = hooks;
+    this.gitRefUpdated = gitRefUpdated;
     this.user = user;
   }
 
@@ -84,6 +89,8 @@
       throws IOException, NoSuchProjectException, ConfigInvalidException {
     RevCommit commit = config.commit(md);
 
+    gitRefUpdated.fire(config.getProject().getNameKey(), RefNames.REFS_CONFIG,
+        base, commit.getId());
     hooks.doRefUpdatedHook(
       new Branch.NameKey(config.getProject().getNameKey(), RefNames.REFS_CONFIG),
       base, commit.getId(), user.getAccount());
diff --git a/gerrit-launcher/BUCK b/gerrit-launcher/BUCK
index 687e02f..5be25fa 100644
--- a/gerrit-launcher/BUCK
+++ b/gerrit-launcher/BUCK
@@ -4,6 +4,7 @@
   name = 'launcher',
   srcs = ['src/main/java/com/google/gerrit/launcher/GerritLauncher.java'],
   visibility = [
+    '//gerrit-acceptance-framework/...',
     '//gerrit-acceptance-tests/...',
     '//gerrit-httpd:',
     '//gerrit-main:main_lib',
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 fb54bcf..8c05a57 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
@@ -32,13 +32,16 @@
 import java.net.URLClassLoader;
 import java.nio.file.FileSystem;
 import java.nio.file.FileSystems;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.security.CodeSource;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Enumeration;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.jar.Attributes;
@@ -300,9 +303,10 @@
   }
 
   private static volatile File myArchive;
-  private static volatile FileSystem myArchiveFs;
   private static volatile File myHome;
 
+  private static final Map<Path, FileSystem> zipFileSystems = new HashMap<>();
+
   /**
    * Locate the JAR/WAR file we were launched from.
    *
@@ -319,19 +323,54 @@
           return result;
         }
         result = locateMyArchive();
-        myArchiveFs = FileSystems.newFileSystem(
-            URI.create("jar:" + result.toPath().toUri()),
-            Collections.<String, String> emptyMap());
         myArchive = result;
       }
     }
     return result;
   }
 
-  public static FileSystem getDistributionArchiveFileSystem()
-      throws FileNotFoundException, IOException {
-    getDistributionArchive();
-    return myArchiveFs;
+  public static synchronized FileSystem getZipFileSystem(Path zip)
+      throws IOException {
+    // FileSystems canonicalizes the path, so we should too.
+    zip = zip.toRealPath();
+    FileSystem zipFs = zipFileSystems.get(zip);
+    if (zipFs == null) {
+      zipFs = newZipFileSystem(zip);
+      zipFileSystems.put(zip, zipFs);
+    }
+    return zipFs;
+  }
+
+  /**
+   * Reload the zip {@link FileSystem} for a path.
+   * <p>
+   * <strong>Warning</strong>: This calls {@link FileSystem#close()} on any
+   * previously open instance of the filesystem at this path, which may cause
+   * {@code IOException}s in any open path handles created with the old
+   * filesystem. Use with caution.
+   *
+   * @param zip path to zip file.
+   * @return reloaded filesystem instance.
+   * @throws IOException if there was an error reading the zip file.
+   */
+  public static synchronized FileSystem reloadZipFileSystem(Path zip)
+      throws IOException {
+    // FileSystems canonicalizes the path, so we should too.
+    zip = zip.toRealPath();
+    @SuppressWarnings("resource") // Caching resource for later use.
+    FileSystem zipFs = zipFileSystems.get(zip);
+    if (zipFs != null) {
+      zipFs.close();
+    }
+    zipFs = newZipFileSystem(zip);
+    zipFileSystems.put(zip, zipFs);
+    return zipFs;
+  }
+
+  private static FileSystem newZipFileSystem(Path zip) throws IOException {
+    return FileSystems.newFileSystem(
+        URI.create("jar:" + zip.toUri()),
+        Collections.<String, String> emptyMap());
   }
 
   private static File locateMyArchive() throws FileNotFoundException {
@@ -562,58 +601,68 @@
   }
 
   /**
+   * Locate the path of the {@code eclipse-out} directory in a source tree.
+   *
+   * @throws FileNotFoundException if the directory cannot be found.
+   */
+  public static Path getDeveloperEclipseOut() throws FileNotFoundException {
+    return resolveInSourceRoot("eclipse-out");
+  }
+
+  /**
    * Locate the path of the {@code buck-out} directory in a source tree.
    *
    * @throws FileNotFoundException if the directory cannot be found.
    */
   public static Path getDeveloperBuckOut() throws FileNotFoundException {
-    // Find ourselves in the CLASSPATH, we should be a loose class file.
+    return resolveInSourceRoot("buck-out");
+  }
+
+  private static Path resolveInSourceRoot(String name)
+      throws FileNotFoundException {
+    // Find ourselves in the classpath, as a loose class file or jar.
     Class<GerritLauncher> self = GerritLauncher.class;
     URL u = self.getResource(self.getSimpleName() + ".class");
     if (u == null) {
       throw new FileNotFoundException("Cannot find class " + self.getName());
-    } else if (!"file".equals(u.getProtocol())) {
-      throw new FileNotFoundException("Cannot find extract path from " + u);
-    }
-
-    // Pop up to the top level classes folder that contains us.
-    Path dir = Paths.get(u.getPath());
-    String myName = self.getName();
-    for (;;) {
-      int dot = myName.lastIndexOf('.');
-      if (dot < 0) {
-        dir = dir.getParent();
-        break;
+    } else if ("jar".equals(u.getProtocol())) {
+      String p = u.getPath();
+      try {
+        u = new URL(p.substring(0, p.indexOf('!')));
+      } catch (MalformedURLException e) {
+        FileNotFoundException fnfe =
+            new FileNotFoundException("Not a valid jar file: " + u);
+        fnfe.initCause(e);
+        throw fnfe;
       }
-      myName = myName.substring(0, dot);
-      dir = dir.getParent();
+    }
+    if (!"file".equals(u.getProtocol())) {
+      throw new FileNotFoundException("Cannot extract path from " + u);
     }
 
-    dir = popdir(u, dir, "classes");
-    dir = popdir(u, dir, "eclipse");
-    if (last(dir).equals("buck-out")) {
-      return dir;
+    // Pop up to the top-level source folder by looking for .buckconfig.
+    Path dir = Paths.get(u.getPath());
+    while (!Files.isRegularFile(dir.resolve(".buckconfig"))) {
+      Path parent = dir.getParent();
+      if (parent == null) {
+        throw new FileNotFoundException("Cannot find source root from " + u);
+      }
+      dir = parent;
     }
-    throw new FileNotFoundException("Cannot find buck-out from " + u);
-  }
 
-  private static String last(Path dir) {
-    return dir.getName(dir.getNameCount() - 1).toString();
-  }
-
-  private static Path popdir(URL u, Path dir, String name)
-      throws FileNotFoundException {
-    if (last(dir).equals(name)) {
-      return dir.getParent();
+    Path ret = dir.resolve(name);
+    if (!Files.exists(ret)) {
+      throw new FileNotFoundException(
+          name + " not found in source root " + dir);
     }
-    throw new FileNotFoundException("Cannot find buck-out from " + u);
+    return ret;
   }
 
   private static ClassLoader useDevClasspath()
       throws MalformedURLException, FileNotFoundException {
-    Path out = getDeveloperBuckOut();
+    Path out = getDeveloperEclipseOut();
     List<URL> dirs = new ArrayList<>();
-    dirs.add(out.resolve("eclipse").resolve("classes").toUri().toURL());
+    dirs.add(out.resolve("classes").toUri().toURL());
     ClassLoader cl = GerritLauncher.class.getClassLoader();
     for (URL u : ((URLClassLoader) cl).getURLs()) {
       if (includeJar(u)) {
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 5c3b629..c05c8f0 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
@@ -131,7 +131,6 @@
   private static final String APPROVAL_FIELD = ChangeField.APPROVAL.getName();
   private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
   private static final String DELETED_FIELD = ChangeField.DELETED.getName();
-  private static final String ID_FIELD = ChangeField.LEGACY_ID2.getName();
   private static final String MERGEABLE_FIELD = ChangeField.MERGEABLE.getName();
   private static final String PATCH_SET_FIELD = ChangeField.PATCH_SET.getName();
   private static final String REVIEWEDBY_FIELD =
@@ -139,10 +138,6 @@
   private static final String UPDATED_SORT_FIELD =
       sortFieldName(ChangeField.UPDATED);
 
-  private static final ImmutableSet<String> FIELDS = ImmutableSet.of(
-      ADDED_FIELD, APPROVAL_FIELD, CHANGE_FIELD, DELETED_FIELD, ID_FIELD,
-      MERGEABLE_FIELD, PATCH_SET_FIELD, REVIEWEDBY_FIELD);
-
   private static final Map<String, String> CUSTOM_CHAR_MAPPING = ImmutableMap.of(
       "_", " ", ".", " ");
 
@@ -438,10 +433,12 @@
 
         List<ChangeData> result =
             Lists.newArrayListWithCapacity(docs.scoreDocs.length);
+        Set<String> fields = fields(opts);
+        String idFieldName = idFieldName();
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
           ScoreDoc sd = docs.scoreDocs[i];
-          Document doc = searchers[sd.shardIndex].doc(sd.doc, FIELDS);
-          result.add(toChangeData(doc));
+          Document doc = searchers[sd.shardIndex].doc(sd.doc, fields);
+          result.add(toChangeData(doc, fields, idFieldName));
         }
 
         final List<ChangeData> r = Collections.unmodifiableList(result);
@@ -477,19 +474,62 @@
     }
   }
 
-  private ChangeData toChangeData(Document doc) {
+  @SuppressWarnings("deprecation")
+  private Set<String> fields(QueryOptions opts) {
+    if (schemaHasRequestedField(ChangeField.LEGACY_ID2, opts.fields())
+        || schemaHasRequestedField(ChangeField.CHANGE, opts.fields())
+        || schemaHasRequestedField(ChangeField.LEGACY_ID, opts.fields())) {
+      return opts.fields();
+    }
+    // Request the numeric ID field even if the caller did not request it,
+    // otherwise we can't actually construct a ChangeData.
+    return Sets.union(opts.fields(), ImmutableSet.of(idFieldName()));
+  }
+
+  private boolean schemaHasRequestedField(FieldDef<ChangeData, ?> field,
+      Set<String> requested) {
+    return schema.hasField(field) && requested.contains(field.getName());
+  }
+
+  @SuppressWarnings("deprecation")
+  private String idFieldName() {
+    return schema.getField(ChangeField.LEGACY_ID2, ChangeField.LEGACY_ID).get()
+        .getName();
+  }
+
+  private ChangeData toChangeData(Document doc, Set<String> fields,
+      String idFieldName) {
+    ChangeData cd;
+    // Either change or the ID field was guaranteed to be included in the call
+    // to fields() above.
     BytesRef cb = doc.getBinaryValue(CHANGE_FIELD);
-    if (cb == null) {
-      int id = doc.getField(ID_FIELD).numericValue().intValue();
-      return changeDataFactory.create(db.get(), new Change.Id(id));
+    if (cb != null) {
+      cd = changeDataFactory.create(db.get(),
+          ChangeProtoField.CODEC.decode(cb.bytes, cb.offset, cb.length));
+    } else {
+      int id = doc.getField(idFieldName).numericValue().intValue();
+      cd = changeDataFactory.create(db.get(), new Change.Id(id));
     }
 
-    // Change proto.
-    Change change = ChangeProtoField.CODEC.decode(
-        cb.bytes, cb.offset, cb.length);
-    ChangeData cd = changeDataFactory.create(db.get(), change);
+    if (fields.contains(PATCH_SET_FIELD)) {
+      decodePatchSets(doc, cd);
+    }
+    if (fields.contains(APPROVAL_FIELD)) {
+      decodeApprovals(doc, cd);
+    }
+    if (fields.contains(ADDED_FIELD) && fields.contains(DELETED_FIELD)) {
+      decodeChangedLines(doc, cd);
+    }
+    if (fields.contains(MERGEABLE_FIELD)) {
+      decodeMergeable(doc, cd);
+    }
+    if (fields.contains(REVIEWEDBY_FIELD)) {
+      decodeReviewedBy(doc, cd);
+    }
+    return cd;
+  }
 
-    // Patch sets.
+  private void decodePatchSets(Document doc, ChangeData cd) {
     List<PatchSet> patchSets =
         decodeProtos(doc, PATCH_SET_FIELD, PatchSetProtoField.CODEC);
     if (!patchSets.isEmpty()) {
@@ -497,12 +537,14 @@
       // this cannot be valid since a change needs at least one patch set.
       cd.setPatchSets(patchSets);
     }
+  }
 
-    // Approvals.
+  private void decodeApprovals(Document doc, ChangeData cd) {
     cd.setCurrentApprovals(
         decodeProtos(doc, APPROVAL_FIELD, PatchSetApprovalProtoField.CODEC));
+  }
 
-    // Changed lines.
+  private void decodeChangedLines(Document doc, ChangeData cd) {
     IndexableField added = doc.getField(ADDED_FIELD);
     IndexableField deleted = doc.getField(DELETED_FIELD);
     if (added != null && deleted != null) {
@@ -510,16 +552,18 @@
           added.numericValue().intValue(),
           deleted.numericValue().intValue());
     }
+  }
 
-    // Mergeable.
+  private void decodeMergeable(Document doc, ChangeData cd) {
     String mergeable = doc.get(MERGEABLE_FIELD);
     if ("1".equals(mergeable)) {
       cd.setMergeable(true);
     } else if ("0".equals(mergeable)) {
       cd.setMergeable(false);
     }
+  }
 
-    // Reviewed-by.
+  private void decodeReviewedBy(Document doc, ChangeData cd) {
     IndexableField[] reviewedBy = doc.getFields(REVIEWEDBY_FIELD);
     if (reviewedBy.length > 0) {
       Set<Account.Id> accounts =
@@ -533,8 +577,6 @@
       }
       cd.setReviewedBy(accounts);
     }
-
-    return cd;
   }
 
   private static <T> List<T> decodeProtos(Document doc, String fieldName,
diff --git a/gerrit-openid/BUCK b/gerrit-openid/BUCK
index 78abce8..0a6363b 100644
--- a/gerrit-openid/BUCK
+++ b/gerrit-openid/BUCK
@@ -3,6 +3,9 @@
   srcs = glob(['src/main/java/**/*.java']),
   resources = glob(['src/main/resources/**/*']),
   deps = [
+    '//lib/openid:consumer',
+  ],
+  provided_deps = [
     '//gerrit-common:annotations',
     '//gerrit-common:server',
     '//gerrit-extension-api:api',
@@ -12,13 +15,12 @@
     '//gerrit-server:server',
     '//lib:guava',
     '//lib:gwtorm',
+    '//lib:servlet-api-3_1',
     '//lib/commons:codec',
     '//lib/guice:guice',
     '//lib/guice:guice-servlet',
     '//lib/jgit:jgit',
     '//lib/log:api',
-    '//lib/openid:consumer',
   ],
-  provided_deps = ['//lib:servlet-api-3_1'],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-pgm/BUCK b/gerrit-pgm/BUCK
index c57ec52..3a14ffd 100644
--- a/gerrit-pgm/BUCK
+++ b/gerrit-pgm/BUCK
@@ -3,23 +3,26 @@
 
 INIT_API_SRCS = glob([SRCS + 'init/api/*.java'])
 
-DEPS = [
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-gwtexpui:linker_server',
-    '//gerrit-gwtexpui:server',
-    '//gerrit-httpd:httpd',
-    '//gerrit-server:server',
-    '//gerrit-sshd:sshd',
-    '//gerrit-reviewdb:server',
-    '//lib:guava',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit:jgit',
-    '//lib/log:api',
-    '//lib/log:jsonevent-layout',
-    '//lib/log:log4j'
+BASE_JETTY_DEPS = [
+  '//gerrit-common:server',
+  '//gerrit-extension-api:api',
+  '//gerrit-gwtexpui:linker_server',
+  '//gerrit-gwtexpui:server',
+  '//gerrit-httpd:httpd',
+  '//gerrit-server:server',
+  '//gerrit-sshd:sshd',
+  '//lib:guava',
+  '//lib/guice:guice',
+  '//lib/guice:guice-assistedinject',
+  '//lib/guice:guice-servlet',
+  '//lib/jgit:jgit',
+  '//lib/log:api',
+  '//lib/log:log4j',
+]
+
+DEPS = BASE_JETTY_DEPS + [
+  '//gerrit-reviewdb:server',
+  '//lib/log:jsonevent-layout',
 ]
 
 java_library(
@@ -83,24 +86,31 @@
   name = 'util-nodep',
   srcs = glob([SRCS + 'util/*.java']),
   provided_deps = DEPS + REST_UTIL_DEPS,
-  visibility = [
-    '//gerrit-acceptance-framework/...',
-  ],
+  visibility = ['//gerrit-acceptance-framework/...'],
 )
 
+JETTY_DEPS = [
+  '//lib/jetty:jmx',
+  '//lib/jetty:server',
+  '//lib/jetty:servlet',
+]
+
 java_library(
   name = 'http',
-  srcs = glob([SRCS + 'http/**/*.java']),
-  deps = DEPS + [
-    '//lib/jetty:jmx',
-    '//lib/jetty:server',
-    '//lib/jetty:servlet',
-  ],
-  provided_deps = [
+  deps = DEPS + JETTY_DEPS,
+  exported_deps = [':http-jetty'],
+  visibility = ['//gerrit-war:'],
+)
+
+java_library(
+  name = 'http-jetty',
+  srcs = glob([SRCS + 'http/jetty/*.java']),
+  provided_deps = JETTY_DEPS + BASE_JETTY_DEPS + [
     '//gerrit-launcher:launcher',
+    '//gerrit-reviewdb:client',
     '//lib:servlet-api-3_1',
   ],
-  visibility = ['//gerrit-war:'],
+  visibility = ['//gerrit-acceptance-framework/...'],
 )
 
 REST_PGM_DEPS = [
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 ee1b111..2815099 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
@@ -28,13 +28,17 @@
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
 import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
 import com.google.gerrit.httpd.RequestContextFilter;
+import com.google.gerrit.httpd.RequestMetricsFilter;
+import com.google.gerrit.httpd.RequireSslFilter;
 import com.google.gerrit.httpd.WebModule;
 import com.google.gerrit.httpd.WebSshGlueModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
+import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lucene.LuceneIndexModule;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.http.jetty.JettyEnv;
 import com.google.gerrit.pgm.http.jetty.JettyModule;
 import com.google.gerrit.pgm.http.jetty.ProjectQoSFilter;
@@ -138,6 +142,9 @@
   @Option(name = "--headless", usage = "Don't start the UI frontend")
   private boolean headless;
 
+  @Option(name = "--polygerrit-dev", usage = "Force PolyGerrit UI for development")
+  private boolean polyGerritDev;
+
   @Option(name = "--init", aliases = {"-i"},
       usage = "Init site before starting the daemon")
   private boolean doInit;
@@ -273,7 +280,7 @@
   @VisibleForTesting
   public void start() throws IOException {
     if (dbInjector == null) {
-      dbInjector = createDbInjector(MULTI_USER);
+      dbInjector = createDbInjector(true /* enableMetrics */, MULTI_USER);
     }
     cfgInjector = createCfgInjector();
     config = cfgInjector.getInstance(
@@ -324,6 +331,7 @@
   private Injector createSysInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(SchemaVersionCheck.module());
+    modules.add(new DropWizardMetricMaker.RestModule());
     modules.add(new LogFileCompressor.Module());
     modules.add(new WorkQueue.Module());
     modules.add(new ChangeHookRunner.Module());
@@ -367,7 +375,8 @@
     modules.add(new AbstractModule() {
       @Override
       protected void configure() {
-        bind(GerritOptions.class).toInstance(new GerritOptions(headless, slave));
+        bind(GerritOptions.class).toInstance(
+            new GerritOptions(config, headless, slave, polyGerritDev));
         if (test) {
           bind(String.class).annotatedWith(SecureStoreClassName.class)
               .toInstance(DefaultSecureStore.class.getName());
@@ -446,9 +455,12 @@
     }
     modules.add(RequestContextFilter.module());
     modules.add(AllRequestFilter.module());
+    modules.add(RequestMetricsFilter.module());
     modules.add(H2CacheBasedWebSession.module());
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
     modules.add(sysInjector.getInstance(WebModule.class));
+    modules.add(sysInjector.getInstance(StaticModule.class));
+    modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
     modules.add(new HttpPluginModule());
     if (sshd) {
       modules.add(sshInjector.getInstance(WebSshGlueModule.class));
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 dd5fe0c..1a43c38 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
@@ -117,11 +117,12 @@
         sysInjector.getInstance(AllUsersName.class);
     try (Repository allUsersRepo =
         repoManager.openMetadataRepository(allUsersName)) {
-      deleteDraftRefs(allUsersRepo);
+      deleteRefs(RefNames.REFS_DRAFT_COMMENTS, allUsersRepo);
+      deleteRefs(RefNames.REFS_STARRED_CHANGES, allUsersRepo);
       for (final Project.NameKey project : changesByProject.keySet()) {
         try (Repository repo = repoManager.openMetadataRepository(project)) {
           final BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-          final BatchRefUpdate bruForDrafts =
+          final BatchRefUpdate bruAllUsers =
               allUsersRepo.getRefDatabase().newBatchUpdate();
           List<ListenableFuture<?>> futures = Lists.newArrayList();
 
@@ -136,7 +137,7 @@
 
           for (final Change c : changesByProject.get(project)) {
             final ListenableFuture<?> future = rebuilder.rebuildAsync(c,
-                executor, bru, bruForDrafts, repo, allUsersRepo);
+                executor, bru, bruAllUsers, repo, allUsersRepo);
             futures.add(future);
             future.addListener(
                 new RebuildListener(c.getId(), future, ok, doneTask, failedTask),
@@ -149,7 +150,7 @@
                 public ListenableFuture<Void> apply(List<?> input)
                     throws Exception {
                   execute(bru, repo);
-                  execute(bruForDrafts, allUsersRepo);
+                  execute(bruAllUsers, allUsersRepo);
                   mpm.end();
                   return Futures.immediateFuture(null);
                 }
@@ -173,15 +174,22 @@
     try (RevWalk rw = new RevWalk(repo)) {
       bru.execute(rw, NullProgressMonitor.INSTANCE);
     }
+    for (ReceiveCommand command : bru.getCommands()) {
+      if (command.getResult() != ReceiveCommand.Result.OK) {
+        throw new IOException(String.format("Command %s failed: %s",
+            command.toString(), command.getResult()));
+      }
+    }
   }
 
-  private void deleteDraftRefs(Repository allUsersRepo) throws IOException {
+  private void deleteRefs(String prefix, Repository allUsersRepo)
+      throws IOException {
     RefDatabase refDb = allUsersRepo.getRefDatabase();
-    Map<String, Ref> allRefs = refDb.getRefs(RefNames.REFS_DRAFT_COMMENTS);
+    Map<String, Ref> allRefs = refDb.getRefs(prefix);
     BatchRefUpdate bru = refDb.newBatchUpdate();
     for (Map.Entry<String, Ref> ref : allRefs.entrySet()) {
       bru.addCommand(new ReceiveCommand(ref.getValue().getObjectId(),
-          ObjectId.zeroId(), RefNames.REFS_DRAFT_COMMENTS + ref.getKey()));
+          ObjectId.zeroId(), prefix + ref.getKey()));
     }
     execute(bru, allUsersRepo);
   }
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 0684650..adfea594 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
@@ -23,6 +23,7 @@
 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;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Singleton;
@@ -127,11 +128,14 @@
   private boolean reverseProxy;
 
   @Inject
-  JettyServer(@GerritServerConfig final Config cfg, final SitePaths site,
-      final JettyEnv env, final HttpLogFactory httpLogFactory) {
+  JettyServer(@GerritServerConfig Config cfg,
+      ThreadSettingsConfig threadSettingsConfig,
+      SitePaths site,
+      JettyEnv env,
+      HttpLogFactory httpLogFactory) {
     this.site = site;
 
-    httpd = new Server(threadPool(cfg));
+    httpd = new Server(threadPool(cfg, threadSettingsConfig));
     httpd.setConnectors(listen(httpd, cfg));
 
     Handler app = makeContext(env, cfg);
@@ -315,8 +319,8 @@
     return site.resolve(path);
   }
 
-  private ThreadPool threadPool(Config cfg) {
-    int maxThreads = cfg.getInt("httpd", null, "maxthreads", 25);
+  private ThreadPool threadPool(Config cfg, ThreadSettingsConfig threadSettingsConfig) {
+    int maxThreads = threadSettingsConfig.getHttpdMaxThreads();
     int minThreads = cfg.getInt("httpd", null, "minthreads", 5);
     int maxQueued = cfg.getInt("httpd", null, "maxqueued", 200);
     int idleTimeout = (int)MILLISECONDS.convert(60, SECONDS);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
index c30edf8..bc9ce8c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
@@ -23,5 +23,5 @@
    * Performs database platform specific configuration steps and writes
    * configuration parameters into the given database section
    */
-  public void initConfig(Section databaseSection);
+  void initConfig(Section databaseSection);
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java
index 6b7386d..68af83f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java
@@ -33,7 +33,7 @@
      *         IOException caused by dealing with the InputStream back to the
      *         caller
      */
-    public void process(String pluginName, InputStream in) throws IOException;
+    void process(String pluginName, InputStream in) throws IOException;
   }
 
   /**
@@ -45,7 +45,7 @@
    * @throws IOException in case of any other IO error caused by reading the
    *         plugin input stream
    */
-  public void foreach(Processor processor) throws FileNotFoundException, IOException;
+  void foreach(Processor processor) throws FileNotFoundException, IOException;
 
   /**
    * List plugins included in the Gerrit distribution
@@ -53,5 +53,5 @@
    * @throws FileNotFoundException if the location of the plugins couldn't be
    *         determined
    */
-  public List<String> listPluginNames() throws FileNotFoundException;
+  List<String> listPluginNames() throws FileNotFoundException;
 }
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 250cf59..fd28399 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
@@ -16,8 +16,8 @@
 
 /** A single step in the site initialization process. */
 public interface InitStep {
-  public void run() throws Exception;
+  void run() throws Exception;
 
   /** Executed after the site has been initialized */
-  public void postRun() throws Exception;
+  void postRun() throws Exception;
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
index 048c2ee..6443e21 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.pgm.util;
 
 import com.google.gerrit.common.SiteLibraryLoaderUtil;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.inject.Inject;
@@ -37,9 +39,11 @@
   @Inject
   SiteLibraryBasedDataSourceProvider(SitePaths site,
       @GerritServerConfig Config cfg,
+      MetricMaker metrics,
+      ThreadSettingsConfig tsc,
       DataSourceProvider.Context ctx,
       DataSourceType dst) {
-    super(cfg, ctx, dst);
+    super(cfg, metrics, tsc, ctx, dst);
     libdir = site.lib_dir;
   }
 
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 a6f1f93..bf69d9b 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
@@ -22,6 +22,9 @@
 import com.google.gerrit.common.Die;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
@@ -93,7 +96,13 @@
   }
 
   /** @return provides database connectivity and site path. */
-  protected Injector createDbInjector(final DataSourceProvider.Context context) {
+  protected Injector createDbInjector(DataSourceProvider.Context context) {
+    return createDbInjector(false, context);
+  }
+
+  /** @return provides database connectivity and site path. */
+  protected Injector createDbInjector(final boolean enableMetrics,
+      final DataSourceProvider.Context context) {
     final Path sitePath = getSitePath();
     final List<Module> modules = new ArrayList<>();
 
@@ -107,6 +116,17 @@
     };
     modules.add(sitePathModule);
 
+    if (enableMetrics) {
+      modules.add(new DropWizardMetricMaker.ApiModule());
+    } else {
+      modules.add(new AbstractModule() {
+        @Override
+        protected void configure() {
+          bind(MetricMaker.class).to(DisabledMetricMaker.class);
+        }
+      });
+    }
+
     modules.add(new LifecycleModule() {
       @Override
       protected void configure() {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java
index 44361aa..dfe0403 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.pgm.util;
 
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.schema.DataSourceProvider;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -34,14 +34,15 @@
     return limitThreads(
         dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class)),
         dbInjector.getInstance(DataSourceType.class),
+        dbInjector.getInstance(ThreadSettingsConfig.class),
         threads);
   }
 
-  private static int limitThreads(Config cfg, DataSourceType dst, int threads) {
+  private static int limitThreads(Config cfg, DataSourceType dst,
+      ThreadSettingsConfig threadSettingsConfig, int threads) {
     boolean usePool = cfg.getBoolean("database", "connectionpool",
         dst.usePool());
-    int poolLimit = cfg.getInt("database", "poollimit",
-        DataSourceProvider.DEFAULT_POOL_LIMIT);
+    int poolLimit = threadSettingsConfig.getDatabasePoolLimit();
     if (usePool && threads > poolLimit) {
       log.warn("Limiting program to " + poolLimit
           + " threads due to database.poolLimit");
diff --git a/gerrit-plugin-api/BUCK b/gerrit-plugin-api/BUCK
index abcacf9..b58c10a 100644
--- a/gerrit-plugin-api/BUCK
+++ b/gerrit-plugin-api/BUCK
@@ -28,6 +28,7 @@
     '//gerrit-gwtexpui:server',
     '//gerrit-reviewdb:server',
     '//lib:args4j',
+    '//lib/dropwizard:dropwizard-core',
     '//lib:guava',
     '//lib:gwtorm',
     '//lib:jsch',
diff --git a/gerrit-plugin-api/pom.xml b/gerrit-plugin-api/pom.xml
index 86d2e4b9..337f5e6 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.12</version>
+  <version>2.13-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 10564de..55de829 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.12</version>
+  <version>2.13-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-gwt-archetype/pom.xml b/gerrit-plugin-gwt-archetype/pom.xml
index 0d7bfb6..91e4b69 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.12</version>
+  <version>2.13-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-gwtui/pom.xml b/gerrit-plugin-gwtui/pom.xml
index 1e2c7b9..c02098c 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.12</version>
+  <version>2.13-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-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java
index 6d4e719..1fff691 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java
@@ -56,7 +56,7 @@
      *
      * @param panel panel that will contain the panel widget.
      */
-    public void onLoad(Panel panel);
+    void onLoad(Panel panel);
   }
 
   static final class Context extends JavaScriptObject {
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java
index 2a872fd..f91464f 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java
@@ -58,7 +58,7 @@
      *
      * @param screen panel that will contain the screen widget.
      */
-    public void onLoad(Screen screen);
+    void onLoad(Screen screen);
   }
 
   static final class Context extends JavaScriptObject {
diff --git a/gerrit-plugin-js-archetype/pom.xml b/gerrit-plugin-js-archetype/pom.xml
index dc7e832..0565b80 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.12</version>
+  <version>2.13-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-prettify/src/main/java/com/google/gerrit/prettify/client/SparseHtmlFile.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/SparseHtmlFile.java
index 0c2af36..3fd34bf 100644
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/SparseHtmlFile.java
+++ b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/client/SparseHtmlFile.java
@@ -18,14 +18,14 @@
 
 public interface SparseHtmlFile {
   /** @return the line of formatted HTML. */
-  public SafeHtml getSafeHtmlLine(int lineNo);
+  SafeHtml getSafeHtmlLine(int lineNo);
 
   /** @return the number of lines in this sparse list. */
-  public int size();
+  int size();
 
   /** @return true if the line is valid in this sparse list. */
-  public boolean contains(int idx);
+  boolean contains(int idx);
 
   /** @return true if this line ends in the middle of a character edit span. */
-  public boolean hasTrailingEdit(int idx);
+  boolean hasTrailingEdit(int idx);
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGeneralPreferences.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGeneralPreferences.java
index 2e33575..83a6ca8 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGeneralPreferences.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGeneralPreferences.java
@@ -76,6 +76,12 @@
     UNIFIED_DIFF
   }
 
+  public static enum EmailStrategy {
+    ENABLED,
+    CC_ON_OWN_COMMENTS,
+    DISABLED
+  }
+
   public static enum TimeFormat {
     /** 12-hour clock: 1:15 am, 2:13 pm */
     HHMM_12("h:mm a"),
@@ -120,9 +126,7 @@
   @Column(id = 6, length = 20, notNull = false)
   protected String downloadCommand;
 
-  /** If true we CC the user on their own changes. */
-  @Column(id = 7)
-  protected boolean copySelfOnEmail;
+  // DELETED: id = 7 (copySelfOnEmail)
 
   @Column(id = 8, length = 10, notNull = false)
   protected String dateFormat;
@@ -155,6 +159,9 @@
   @Column(id = 19)
   protected boolean muteCommonPathPrefixes;
 
+  @Column(id = 20, length = 30, notNull = false)
+  protected String emailStrategy;
+
   public AccountGeneralPreferences() {
   }
 
@@ -242,14 +249,6 @@
     }
   }
 
-  public boolean isCopySelfOnEmails() {
-    return copySelfOnEmail;
-  }
-
-  public void setCopySelfOnEmails(boolean includeSelfOnEmail) {
-    copySelfOnEmail = includeSelfOnEmail;
-  }
-
   public boolean isShowInfoInReviewCategory() {
     return getReviewCategoryStrategy() != ReviewCategoryStrategy.NONE;
   }
@@ -307,6 +306,17 @@
     this.diffView = diffView.name();
   }
 
+  public EmailStrategy getEmailStrategy() {
+    if (emailStrategy == null) {
+      return EmailStrategy.ENABLED;
+    }
+    return EmailStrategy.valueOf(emailStrategy);
+  }
+
+  public void setEmailStrategy(EmailStrategy strategy) {
+    this.emailStrategy = strategy.name();
+  }
+
   public boolean isSizeBarInChangeTable() {
     return sizeBarInChangeTable;
   }
@@ -336,7 +346,6 @@
     maximumPageSize = DEFAULT_PAGESIZE;
     showSiteHeader = true;
     useFlashClipboard = true;
-    copySelfOnEmail = false;
     reviewCategoryStrategy = null;
     downloadUrl = null;
     downloadCommand = null;
@@ -347,5 +356,6 @@
     sizeBarInChangeTable = true;
     legacycidInChangeTable = false;
     muteCommonPathPrefixes = true;
+    emailStrategy = null;
   }
 }
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 5d2a1fd..d6ba008 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
@@ -40,8 +40,12 @@
   /** Configurations of project-specific dashboards (canned search queries). */
   public static final String REFS_DASHBOARDS = "refs/meta/dashboards/";
 
+  /** Draft inline comments of a user on a change */
   public static final String REFS_DRAFT_COMMENTS = "refs/draft-comments/";
 
+  /** A change starred by a user */
+  public static final String REFS_STARRED_CHANGES = "refs/starred-changes/";
+
   /**
    * Prefix applied to merge commit base nodes.
    * <p>
@@ -88,8 +92,30 @@
 
   public static String refsDraftComments(Account.Id accountId,
       Change.Id changeId) {
+    StringBuilder r = buildRefsPrefix(REFS_DRAFT_COMMENTS, accountId);
+    r.append(changeId.get());
+    return r.toString();
+  }
+
+  public static String refsDraftCommentsPrefix(Account.Id accountId) {
+    return buildRefsPrefix(REFS_DRAFT_COMMENTS, accountId).toString();
+  }
+
+  public static String refsStarredChanges(Account.Id accountId,
+      Change.Id changeId) {
+    StringBuilder r = buildRefsPrefix(REFS_STARRED_CHANGES, accountId);
+    r.append(changeId.get());
+    return r.toString();
+  }
+
+  public static String refsStarredChangesPrefix(Account.Id accountId) {
+    return buildRefsPrefix(REFS_STARRED_CHANGES, accountId).toString();
+  }
+
+  private static StringBuilder buildRefsPrefix(String prefix,
+      Account.Id accountId) {
     StringBuilder r = new StringBuilder();
-    r.append(REFS_DRAFT_COMMENTS);
+    r.append(prefix);
     int n = accountId.get() % 100;
     if (n < 10) {
       r.append('0');
@@ -97,9 +123,8 @@
     r.append(n);
     r.append('/');
     r.append(accountId.get());
-    r.append('-');
-    r.append(changeId.get());
-    return r.toString();
+    r.append('/');
+    return r;
   }
 
   /**
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
index b0981a7..844f893 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
@@ -39,7 +39,25 @@
   @Test
   public void refsDraftComments() throws Exception {
     assertThat(RefNames.refsDraftComments(accountId, changeId))
-      .isEqualTo("refs/draft-comments/23/1011123-67473");
+      .isEqualTo("refs/draft-comments/23/1011123/67473");
+  }
+
+  @Test
+  public void refsDraftCommentsPrefix() throws Exception {
+    assertThat(RefNames.refsDraftCommentsPrefix(accountId))
+      .isEqualTo("refs/draft-comments/23/1011123/");
+  }
+
+  @Test
+  public void refsStarredChanges() throws Exception {
+    assertThat(RefNames.refsStarredChanges(accountId, changeId))
+      .isEqualTo("refs/starred-changes/23/1011123/67473");
+  }
+
+  @Test
+  public void refsStarredChangesPrefix() throws Exception {
+    assertThat(RefNames.refsStarredChangesPrefix(accountId))
+      .isEqualTo("refs/starred-changes/23/1011123/");
   }
 
   @Test
diff --git a/gerrit-server/BUCK b/gerrit-server/BUCK
index c264779..b8fb4d2 100644
--- a/gerrit-server/BUCK
+++ b/gerrit-server/BUCK
@@ -53,6 +53,7 @@
     '//lib/commons:lang',
     '//lib/commons:net',
     '//lib/commons:validator',
+    '//lib/dropwizard:dropwizard-core',
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
     '//lib/guice:guice-servlet',
@@ -97,6 +98,7 @@
   '//lib/guice:guice-servlet',
   '//lib/jgit:jgit',
   '//lib/jgit:junit',
+  '//lib/joda:joda-time',
   '//lib/log:api',
   '//lib/log:impl_log4j',
   '//lib/log:log4j',
@@ -137,6 +139,7 @@
   srcs = PROLOG_TEST_CASE,
   deps = [
     ':server',
+    ':testutil',
     '//gerrit-common:server',
     '//gerrit-extension-api:api',
     '//lib:guava',
@@ -173,7 +176,6 @@
     '//gerrit-common:annotations',
     '//gerrit-server/src/main/prolog:common',
     '//lib/antlr:java_runtime',
-    '//lib/joda:joda-time',
   ],
   source_under_test = [':server'],
 )
@@ -193,8 +195,8 @@
     '//lib:args4j',
     '//lib:grappa',
     '//lib:guava',
+    '//lib/dropwizard:dropwizard-core',
     '//lib/guice:guice-assistedinject',
-    '//lib/joda:joda-time',
     '//lib/prolog:runtime',
   ],
   source_under_test = [':server'],
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java
index 889f008..d9934f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.events.LifecycleListener;
@@ -715,12 +714,12 @@
     }
 
     @Override
-    public void doClaSignupHook(Account account, ContributorAgreement cla) {
+    public void doClaSignupHook(Account account, String claName) {
       if (account != null) {
         List<String> args = new ArrayList<>();
         addArg(args, "--submitter", getDisplayName(account));
         addArg(args, "--user-id", account.getId().toString());
-        addArg(args, "--cla-name", cla.getName());
+        addArg(args, "--cla-name", claName);
 
         runHook(claSignedHook, args);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java
index b16a8a5..a70df51 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.common;
 
 import com.google.gerrit.common.ChangeHookRunner.HookResult;
-import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -39,7 +38,7 @@
    * @param patchSet The Patchset that was created.
    * @throws OrmException
    */
-  public void doPatchsetCreatedHook(Change change, PatchSet patchSet,
+  void doPatchsetCreatedHook(Change change, PatchSet patchSet,
       ReviewDb db) throws OrmException;
 
   /**
@@ -49,7 +48,7 @@
    * @param patchSet The Patchset that was published.
    * @throws OrmException
    */
-  public void doDraftPublishedHook(Change change, PatchSet patchSet,
+  void doDraftPublishedHook(Change change, PatchSet patchSet,
       ReviewDb db) throws OrmException;
 
   /**
@@ -62,7 +61,7 @@
    * @param approvals Map of label IDs to scores
    * @throws OrmException
    */
-  public void doCommentAddedHook(Change change, Account account,
+  void doCommentAddedHook(Change change, Account account,
       PatchSet patchSet, String comment,
       Map<String, Short> approvals, ReviewDb db)
       throws OrmException;
@@ -76,7 +75,7 @@
    * @param mergeResultRev The SHA-1 of the merge result revision.
    * @throws OrmException
    */
-  public void doChangeMergedHook(Change change, Account account,
+  void doChangeMergedHook(Change change, Account account,
       PatchSet patchSet, ReviewDb db, String mergeResultRev) throws OrmException;
 
   /**
@@ -88,7 +87,7 @@
    * @param reason The reason that the change failed to merge.
    * @throws OrmException
    */
-  public void doMergeFailedHook(Change change, Account account,
+  void doMergeFailedHook(Change change, Account account,
       PatchSet patchSet, String reason, ReviewDb db) throws OrmException;
 
   /**
@@ -99,7 +98,7 @@
    * @param reason Reason for abandoning the change.
    * @throws OrmException
    */
-  public void doChangeAbandonedHook(Change change, Account account,
+  void doChangeAbandonedHook(Change change, Account account,
       PatchSet patchSet, String reason, ReviewDb db) throws OrmException;
 
   /**
@@ -110,7 +109,7 @@
    * @param reason Reason for restoring the change.
    * @throws OrmException
    */
-  public void doChangeRestoredHook(Change change, Account account,
+  void doChangeRestoredHook(Change change, Account account,
       PatchSet patchSet, String reason, ReviewDb db) throws OrmException;
 
   /**
@@ -120,7 +119,7 @@
    * @param refUpdate An actual RefUpdate object
    * @param account The gerrit user who moved the ref
    */
-  public void doRefUpdatedHook(Branch.NameKey refName, RefUpdate refUpdate,
+  void doRefUpdatedHook(Branch.NameKey refName, RefUpdate refUpdate,
       Account account);
 
   /**
@@ -131,7 +130,7 @@
    * @param newId The ref's new id
    * @param account The gerrit user who moved the ref
    */
-  public void doRefUpdatedHook(Branch.NameKey refName, ObjectId oldId,
+  void doRefUpdatedHook(Branch.NameKey refName, ObjectId oldId,
       ObjectId newId, Account account);
 
   /**
@@ -141,7 +140,7 @@
    * @param patchSet The patchset that the reviewer was added on.
    * @param account The gerrit user who was added as reviewer.
    */
-  public void doReviewerAddedHook(Change change, Account account,
+  void doReviewerAddedHook(Change change, Account account,
       PatchSet patchSet, ReviewDb db) throws OrmException;
 
   /**
@@ -151,10 +150,10 @@
    * @param account The gerrit user who changed the topic.
    * @param oldTopic The old topic name.
    */
-  public void doTopicChangedHook(Change change, Account account,
+  void doTopicChangedHook(Change change, Account account,
       String oldTopic, ReviewDb db) throws OrmException;
 
-  public void doClaSignupHook(Account account, ContributorAgreement cla);
+  void doClaSignupHook(Account account, String claName);
 
   /**
    * Fire the Ref update Hook
@@ -165,7 +164,7 @@
    * @param oldId The ref's old id
    * @param newId The ref's new id
    */
-  public HookResult doRefUpdateHook(Project project,  String refName,
+  HookResult doRefUpdateHook(Project project,  String refName,
        Account uploader, ObjectId oldId, ObjectId newId);
 
   /**
@@ -178,7 +177,7 @@
    * @param db The database
    * @throws OrmException
    */
-  public void doHashtagsChangedHook(Change change, Account account,
+  void doHashtagsChangedHook(Change change, Account account,
       Set<String>added, Set<String> removed, Set<String> hashtags,
       ReviewDb db) throws OrmException;
 
@@ -188,5 +187,5 @@
    * @param project The project that was created
    * @param headName The head name of the created project
    */
-  public void doProjectCreatedHook(Project.NameKey project, String headName);
+  void doProjectCreatedHook(Project.NameKey project, String headName);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java b/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java
index bed77a7..1e0afd0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.common;
 
 import com.google.gerrit.common.ChangeHookRunner.HookResult;
-import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -59,7 +58,7 @@
   }
 
   @Override
-  public void doClaSignupHook(Account account, ContributorAgreement cla) {
+  public void doClaSignupHook(Account account, String claName) {
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
index b74771f8..f71b5c68 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
@@ -31,7 +31,7 @@
    * @param db The database
    * @throws OrmException
    */
-  public void postEvent(Change change, Event event, ReviewDb db)
+  void postEvent(Change change, Event event, ReviewDb db)
       throws OrmException;
 
   /**
@@ -40,5 +40,5 @@
    * @param branchName The branch that the event is related to
    * @param event The event to post
    */
-  public void postEvent(Branch.NameKey branchName, Event event);
+  void postEvent(Branch.NameKey branchName, Event event);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java
index 7e8a794..97be844 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java
@@ -19,5 +19,5 @@
 
 @ExtensionPoint
 public interface EventListener {
-  public void onEvent(Event event);
+  void onEvent(Event event);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventSource.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventSource.java
index e2c4b34..bde6f5d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventSource.java
@@ -18,7 +18,7 @@
 
 /** Distributes Events to ChangeListeners.  Register listeners here. */
 public interface EventSource {
-  public void addEventListener(EventListener listener, CurrentUser user);
+  void addEventListener(EventListener listener, CurrentUser user);
 
-  public void removeEventListener(EventListener listener);
+  void removeEventListener(EventListener listener);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java
new file mode 100644
index 0000000..1714c7a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java
@@ -0,0 +1,28 @@
+// 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.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+
+/**
+ * Metric whose value is supplied when the trigger is invoked.
+ *
+ * @see CallbackMetric0
+ * @param <V> type of the metric value, typically Integer or Long.
+ */
+public interface CallbackMetric<V> extends RegistrationHandle {
+  void prune();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric0.java
new file mode 100644
index 0000000..829dd22
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric0.java
@@ -0,0 +1,43 @@
+// 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.metrics;
+
+/**
+ * Metric whose value is supplied when the trigger is invoked.
+ *
+ * <pre>
+ *   CallbackMetric0&lt;Long&gt; hits = metricMaker.newCallbackMetric("hits", ...);
+ *   CallbackMetric0&lt;Long&gt; total = metricMaker.newCallbackMetric("total", ...);
+ *   metricMaker.newTrigger(hits, total, new Runnable() {
+ *     public void run() {
+ *       hits.set(1);
+ *       total.set(5);
+ *     }
+ *   });
+ * </pre>
+ *
+ * @param <V> type of the metric value, typically Integer or Long.
+ */
+public abstract class CallbackMetric0<V> implements CallbackMetric<V> {
+  /**
+   * Supply the current value of the metric.
+   *
+   * @param value current value.
+   */
+  public abstract void set(V value);
+
+  @Override
+  public void prune() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java
new file mode 100644
index 0000000..0888e21
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java
@@ -0,0 +1,39 @@
+// 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.metrics;
+
+/**
+ * Metric whose value is supplied when the trigger is invoked.
+ *
+ * @param <F1> type of the field.
+ * @param <V> type of the metric value, typically Integer or Long.
+ */
+public abstract class CallbackMetric1<F1, V> implements CallbackMetric<V> {
+  /**
+   * Supply the current value of the metric.
+   *
+   * @param field1 bucket to increment.
+   * @param value current value.
+   */
+  public abstract void set(F1 field1, V value);
+
+  /** Ensure a zeroed metric is created for the field value. */
+  public abstract void forceCreate(F1 field1);
+
+  /** Prune any submetrics that were not assigned during this trigger. */
+  @Override
+  public void prune() {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java
new file mode 100644
index 0000000..a2af7e4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java
@@ -0,0 +1,41 @@
+// 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.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Metric whose value increments during the life of the process.
+ * <p>
+ * Suitable uses are "total requests handled", "bytes sent", etc.
+ * Use {@link Description#setRate()} to suggest the monitoring system
+ * should also track the rate of increments if this is of interest.
+ * <p>
+ * For an instantaneous read of a value that can change over time
+ * (e.g. "memory in use") use a {@link CallbackMetric}.
+ */
+public abstract class Counter0 implements RegistrationHandle {
+  /** Increment the counter by one event. */
+  public void increment() {
+    incrementBy(1);
+  }
+
+  /**
+   * Increment the counter by a specified amount.
+   *
+   * @param value value to increment by, must be &gt;= 0.
+   */
+  public abstract void incrementBy(long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java
new file mode 100644
index 0000000..8627dcc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java
@@ -0,0 +1,44 @@
+// 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.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Metric whose value increments during the life of the process.
+ * <p>
+ * Suitable uses are "total requests handled", "bytes sent", etc.
+ * Use {@link Description#setRate()} to suggest the monitoring system
+ * should also track the rate of increments if this is of interest.
+ * <p>
+ * For an instantaneous read of a value that can change over time
+ * (e.g. "memory in use") use a {@link CallbackMetric}.
+ *
+ * @param <F1> type of the field.
+ */
+public abstract class Counter1<F1> implements RegistrationHandle {
+  /** Increment the counter by one event. */
+  public void increment(F1 field1) {
+    incrementBy(field1, 1);
+  }
+
+  /**
+   * Increment the counter by a specified amount.
+   *
+   * @param field1 bucket to increment.
+   * @param value value to increment by, must be &gt;= 0.
+   */
+  public abstract void incrementBy(F1 field1, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java
new file mode 100644
index 0000000..40efe95
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java
@@ -0,0 +1,46 @@
+// 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.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Metric whose value increments during the life of the process.
+ * <p>
+ * Suitable uses are "total requests handled", "bytes sent", etc.
+ * Use {@link Description#setRate()} to suggest the monitoring system
+ * should also track the rate of increments if this is of interest.
+ * <p>
+ * For an instantaneous read of a value that can change over time
+ * (e.g. "memory in use") use a {@link CallbackMetric}.
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ */
+public abstract class Counter2<F1, F2> implements RegistrationHandle {
+  /** Increment the counter by one event. */
+  public void increment(F1 field1, F2 field2) {
+    incrementBy(field1, field2, 1);
+  }
+
+  /**
+   * Increment the counter by a specified amount.
+   *
+   * @param field1 bucket to increment.
+   * @param field2 bucket to increment.
+   * @param value value to increment by, must be &gt;= 0.
+   */
+  public abstract void incrementBy(F1 field1, F2 field2, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java
new file mode 100644
index 0000000..d722930
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java
@@ -0,0 +1,48 @@
+// 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.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Metric whose value increments during the life of the process.
+ * <p>
+ * Suitable uses are "total requests handled", "bytes sent", etc.
+ * Use {@link Description#setRate()} to suggest the monitoring system
+ * should also track the rate of increments if this is of interest.
+ * <p>
+ * For an instantaneous read of a value that can change over time
+ * (e.g. "memory in use") use a {@link CallbackMetric}.
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ * @param <F3> type of the field.
+ */
+public abstract class Counter3<F1, F2, F3> implements RegistrationHandle {
+  /** Increment the counter by one event. */
+  public void increment(F1 field1, F2 field2, F3 field3) {
+    incrementBy(field1, field2, field3, 1);
+  }
+
+  /**
+   * Increment the counter by a specified amount.
+   *
+   * @param field1 bucket to increment.
+   * @param field2 bucket to increment.
+   * @param field3 bucket to increment.
+   * @param value value to increment by, must be &gt;= 0.
+   */
+  public abstract void incrementBy(F1 field1, F2 field2, F3 field3, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Description.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Description.java
new file mode 100644
index 0000000..43e5c8a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Description.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.metrics;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/** Describes a metric created by {@link MetricMaker}. */
+public class Description {
+  public static final String DESCRIPTION = "DESCRIPTION";
+  public static final String UNIT = "UNIT";
+  public static final String CUMULATIVE = "CUMULATIVE";
+  public static final String RATE = "RATE";
+  public static final String GAUGE = "GAUGE";
+  public static final String CONSTANT = "CONSTANT";
+  public static final String FIELD_ORDERING = "FIELD_ORDERING";
+  public static final String TRUE_VALUE = "1";
+
+  public static class Units {
+    public static final String SECONDS = "seconds";
+    public static final String MILLISECONDS = "milliseconds";
+    public static final String MICROSECONDS = "microseconds";
+    public static final String NANOSECONDS = "nanoseconds";
+
+    public static final String BYTES = "bytes";
+
+    private Units() {
+    }
+  }
+
+  public static enum FieldOrdering {
+    /** Default ordering places fields at end of the parent metric name. */
+    AT_END,
+
+    /**
+     * Splits the metric name by inserting field values before the last '/' in
+     * the metric name. For example {@code "plugins/replication/push_latency"}
+     * with a {@code Field.ofString("remote")} will create submetrics named
+     * {@code "plugins/replication/some-server/push_latency"}.
+     */
+    PREFIX_FIELDS_BASENAME;
+  }
+
+  private final Map<String, String> annotations;
+
+  /**
+   * Describe a metric.
+   *
+   * @param helpText a short one-sentence string explaining the values captured
+   *        by the metric. This may be made available to administrators as
+   *        documentation in the reporting tools.
+   */
+  public Description(String helpText) {
+    annotations = Maps.newLinkedHashMapWithExpectedSize(4);
+    annotations.put(DESCRIPTION, helpText);
+  }
+
+  /** Unit used to describe the value, e.g. "requests", "seconds", etc. */
+  public Description setUnit(String unitName) {
+    annotations.put(UNIT, unitName);
+    return this;
+  }
+
+  /**
+   * Mark the value as constant for the life of this process. Typically used for
+   * software versions, command line arguments, etc. that cannot change without
+   * a process restart.
+   */
+  public Description setConstant() {
+    annotations.put(CONSTANT, TRUE_VALUE);
+    return this;
+  }
+
+  /**
+   * Indicates the metric may be usefully interpreted as a count over short
+   * periods of time, such as request arrival rate. May only be applied to a
+   * {@link Counter0}.
+   */
+  public Description setRate() {
+    annotations.put(RATE, TRUE_VALUE);
+    return this;
+  }
+
+  /**
+   * Instantaneously sampled value that may increase or decrease at a later
+   * time. Memory allocated or open network connections are examples of gauges.
+   */
+  public Description setGauge() {
+    annotations.put(GAUGE, TRUE_VALUE);
+    return this;
+  }
+
+  /**
+   * Indicates the metric accumulates over the lifespan of the process. A
+   * {@link Counter0} like total requests handled accumulates over the process
+   * and should be {@code setCumulative()}.
+   */
+  public Description setCumulative() {
+    annotations.put(CUMULATIVE, TRUE_VALUE);
+    return this;
+  }
+
+  /** Configure how fields are ordered into submetric names. */
+  public Description setFieldOrdering(FieldOrdering ordering) {
+    annotations.put(FIELD_ORDERING, ordering.name());
+    return this;
+  }
+
+  /** True if the metric value never changes after startup. */
+  public boolean isConstant() {
+    return TRUE_VALUE.equals(annotations.get(CONSTANT));
+  }
+
+  /** True if the metric may be interpreted as a rate over time. */
+  public boolean isRate() {
+    return TRUE_VALUE.equals(annotations.get(RATE));
+  }
+
+  /** True if the metric is an instantaneous sample. */
+  public boolean isGauge() {
+    return TRUE_VALUE.equals(annotations.get(GAUGE));
+  }
+
+  /** True if the metric accumulates over the lifespan of the process. */
+  public boolean isCumulative() {
+    return TRUE_VALUE.equals(annotations.get(CUMULATIVE));
+  }
+
+  /** Get the suggested field ordering. */
+  public FieldOrdering getFieldOrdering() {
+    String o = annotations.get(FIELD_ORDERING);
+    return o != null ? FieldOrdering.valueOf(o) : FieldOrdering.AT_END;
+  }
+
+  /**
+   * Decode the unit as a unit of time.
+   *
+   * @return valid time unit.
+   * @throws IllegalArgumentException if the unit is not a valid unit of time.
+   */
+  public TimeUnit getTimeUnit() {
+    return getTimeUnit(annotations.get(UNIT));
+  }
+
+  private static final ImmutableMap<String, TimeUnit> TIME_UNITS = ImmutableMap.of(
+      Units.NANOSECONDS, TimeUnit.NANOSECONDS,
+      Units.MICROSECONDS, TimeUnit.MICROSECONDS,
+      Units.MILLISECONDS, TimeUnit.MILLISECONDS,
+      Units.SECONDS, TimeUnit.SECONDS);
+
+  public static TimeUnit getTimeUnit(String unit) {
+    if (Strings.isNullOrEmpty(unit)) {
+      throw new IllegalArgumentException("no unit configured");
+    }
+    TimeUnit u = TIME_UNITS.get(unit);
+    if (u == null) {
+      throw new IllegalArgumentException(String.format(
+          "unit %s not TimeUnit", unit));
+    }
+    return u;
+  }
+
+  /** Immutable copy of all annotations (configurable properties). */
+  public ImmutableMap<String, String> getAnnotations() {
+    return ImmutableMap.copyOf(annotations);
+  }
+
+  @Override
+  public String toString() {
+    return annotations.toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java
new file mode 100644
index 0000000..1b05e2c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java
@@ -0,0 +1,155 @@
+// 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.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/** Exports no metrics, useful for running batch programs. */
+public class DisabledMetricMaker extends MetricMaker {
+  @Override
+  public Counter0 newCounter(String name, Description desc) {
+    return new Counter0() {
+      @Override public void incrementBy(long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1> Counter1<F1> newCounter(String name, Description desc,
+      Field<F1> field1) {
+    return new Counter1<F1>() {
+      @Override public void incrementBy(F1 field1, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2> Counter2<F1, F2> newCounter(String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    return new Counter2<F1, F2>() {
+      @Override public void incrementBy(F1 field1, F2 field2, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(String name,
+      Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    return new Counter3<F1, F2, F3>() {
+      @Override public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public Timer0 newTimer(String name, Description desc) {
+    return new Timer0() {
+      @Override public void record(long value, TimeUnit unit) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1> Timer1<F1> newTimer(String name, Description desc,
+      Field<F1> field1) {
+    return new Timer1<F1>() {
+      @Override public void record(F1 field1, long value, TimeUnit unit) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2> Timer2<F1, F2> newTimer(String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    return new Timer2<F1, F2>() {
+      @Override public void record(F1 field1, F2 field2, long value, TimeUnit unit) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(String name,
+      Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    return new Timer3<F1, F2, F3>() {
+      @Override public void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public Histogram0 newHistogram(String name, Description desc) {
+    return new Histogram0() {
+      @Override public void record(long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1> Histogram1<F1> newHistogram(String name, Description desc,
+      Field<F1> field1) {
+    return new Histogram1<F1>() {
+      @Override public void record(F1 field1, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2> Histogram2<F1, F2> newHistogram(String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    return new Histogram2<F1, F2>() {
+      @Override public void record(F1 field1, F2 field2, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(String name,
+      Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    return new Histogram3<F1, F2, F3>() {
+      @Override public void record(F1 field1, F2 field2, F3 field3, long value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <V> CallbackMetric0<V> newCallbackMetric(String name,
+      Class<V> valueClass, Description desc) {
+    return new CallbackMetric0<V>() {
+      @Override public void set(V value) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(String name,
+      Class<V> valueClass, Description desc, Field<F1> field1) {
+    return new CallbackMetric1<F1, V>() {
+      @Override public void set(F1 field1, V value) {}
+      @Override public void forceCreate(F1 field1) {}
+      @Override public void remove() {}
+    };
+  }
+
+  @Override
+  public RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics,
+      Runnable trigger) {
+    return new RegistrationHandle() {
+      @Override public void remove() {}
+    };
+  }
+}
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
new file mode 100644
index 0000000..a91e428
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java
@@ -0,0 +1,136 @@
+// 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.metrics;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Function;
+import com.google.common.base.Functions;
+
+/** Describes a bucketing field used by a metric. */
+public class Field<T> {
+  /** Break down metrics by boolean true/false. */
+  public static Field<Boolean> ofBoolean(String name) {
+    return ofBoolean(name, null);
+  }
+
+  /** Break down metrics by boolean true/false. */
+  public static Field<Boolean> ofBoolean(String name, String description) {
+    return new Field<>(name, Boolean.class, description);
+  }
+
+  /** Break down metrics by cases of an enum. */
+  public static <E extends Enum<E>> Field<E> ofEnum(Class<E> enumType,
+      String name) {
+    return ofEnum(enumType, name, null);
+  }
+
+  /** Break down metrics by cases of an enum. */
+  public static <E extends Enum<E>> Field<E> ofEnum(Class<E> enumType,
+      String name, String description) {
+    return new Field<>(name, enumType, description);
+  }
+
+  /**
+   * Break down metrics by string.
+   * <p>
+   * Each unique string will allocate a new submetric. <b>Do not use user
+   * content as a field value</b> as field values are never reclaimed.
+   */
+  public static Field<String> ofString(String name) {
+    return ofString(name, null);
+  }
+
+  /**
+   * Break down metrics by string.
+   * <p>
+   * Each unique string will allocate a new submetric. <b>Do not use user
+   * content as a field value</b> as field values are never reclaimed.
+   */
+  public static Field<String> ofString(String name, String description) {
+    return new Field<>(name, String.class, description);
+  }
+
+  /**
+   * Break down metrics by integer.
+   * <p>
+   * Each unique integer will allocate a new submetric. <b>Do not use user
+   * content as a field value</b> as field values are never reclaimed.
+   */
+  public static Field<Integer> ofInteger(String name) {
+    return ofInteger(name, null);
+  }
+
+  /**
+   * Break down metrics by integer.
+   * <p>
+   * Each unique integer will allocate a new submetric. <b>Do not use user
+   * content as a field value</b> as field values are never reclaimed.
+   */
+  public static Field<Integer> ofInteger(String name, String description) {
+    return new Field<>(name, Integer.class, description);
+  }
+
+  private final String name;
+  private final Class<T> keyType;
+  private final Function<T, String> formatter;
+  private final String description;
+
+  private Field(String name, Class<T> keyType, String description) {
+    checkArgument(name.matches("^[a-z_]+$"), "name must match [a-z_]");
+    this.name = name;
+    this.keyType = keyType;
+    this.formatter = initFormatter(keyType);
+    this.description = description;
+  }
+
+  /** Name of this field within the metric. */
+  public String getName() {
+    return name;
+  }
+
+  /** Type of value used within the field. */
+  public Class<T> getType() {
+    return keyType;
+  }
+
+  /** Description text for the field explaining its range of values. */
+  public String getDescription() {
+    return description;
+  }
+
+  public Function<T, String> formatter() {
+    return formatter;
+  }
+
+  @SuppressWarnings("unchecked")
+  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();
+        }
+      };
+    }
+    throw new IllegalStateException("unsupported type " + keyType.getName());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java
new file mode 100644
index 0000000..3a6fbd9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java
@@ -0,0 +1,27 @@
+// 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.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Measures the statistical distribution of values in a stream of data.
+ * <p>
+ * Suitable uses are "response size in bytes", etc.
+ */
+public abstract class Histogram0 implements RegistrationHandle {
+  /** Record a sample of a specified amount. */
+  public abstract void record(long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java
new file mode 100644
index 0000000..16df8e4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java
@@ -0,0 +1,29 @@
+// 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.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Measures the statistical distribution of values in a stream of data.
+ * <p>
+ * Suitable uses are "response size in bytes", etc.
+ *
+ * @param <F1> type of the field.
+ */
+public abstract class Histogram1<F1> implements RegistrationHandle {
+  /** Record a sample of a specified amount. */
+  public abstract void record(F1 field1, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java
new file mode 100644
index 0000000..2eca402
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java
@@ -0,0 +1,30 @@
+// 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.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Measures the statistical distribution of values in a stream of data.
+ * <p>
+ * Suitable uses are "response size in bytes", etc.
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ */
+public abstract class Histogram2<F1, F2> implements RegistrationHandle {
+  /** Record a sample of a specified amount. */
+  public abstract void record(F1 field1, F2 field2, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java
new file mode 100644
index 0000000..09a756d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java
@@ -0,0 +1,31 @@
+// 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.metrics;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+/**
+ * Measures the statistical distribution of values in a stream of data.
+ * <p>
+ * Suitable uses are "response size in bytes", etc.
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ * @param <F3> type of the field.
+ */
+public abstract class Histogram3<F1, F2, F3> implements RegistrationHandle {
+  /** Record a sample of a specified amount. */
+  public abstract void record(F1 field1, F2 field2, F3 field3, long value);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
new file mode 100644
index 0000000..2122594
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
@@ -0,0 +1,137 @@
+// 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.metrics;
+
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.Set;
+
+/** Factory to create metrics for monitoring. */
+public abstract class MetricMaker {
+  /** Metric whose value increments during the life of the process. */
+  public abstract Counter0 newCounter(String name, Description desc);
+  public abstract <F1> Counter1<F1> newCounter(
+      String name, Description desc,
+      Field<F1> field1);
+  public abstract <F1, F2> Counter2<F1, F2> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2);
+  public abstract <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3);
+
+  /** Metric recording time spent on an operation. */
+  public abstract Timer0 newTimer(String name, Description desc);
+  public abstract <F1> Timer1<F1> newTimer(
+      String name, Description desc,
+      Field<F1> field1);
+  public abstract <F1, F2> Timer2<F1, F2> newTimer(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2);
+  public abstract <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3);
+
+  /** Metric statistical distribution of values. */
+  public abstract Histogram0 newHistogram(String name, Description desc);
+  public abstract <F1> Histogram1<F1> newHistogram(
+      String name, Description desc,
+      Field<F1> field1);
+  public abstract <F1, F2> Histogram2<F1, F2> newHistogram(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2);
+  public abstract <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3);
+
+  /**
+   * Constant value that does not change.
+   *
+   * @param name unique name of the metric.
+   * @param value only value of the metric.
+   * @param desc description of the metric.
+   */
+  public <V> void newConstantMetric(String name, final V value, Description desc) {
+    desc.setConstant();
+
+    @SuppressWarnings("unchecked")
+    Class<V> type = (Class<V>) value.getClass();
+    final CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
+    newTrigger(metric, new Runnable() {
+      @Override
+      public void run() {
+        metric.set(value);
+      }
+    });
+  }
+
+  /**
+   * Instantaneous reading of a value.
+   *
+   * <pre>
+   * metricMaker.newCallbackMetric(&quot;memory&quot;,
+   *     new Description(&quot;Total bytes of memory used&quot;)
+   *        .setGauge()
+   *        .setUnit(Units.BYTES),
+   *     new Supplier&lt;Long&gt;() {
+   *       public Long get() {
+   *         return Runtime.getRuntime().totalMemory();
+   *       }
+   *     });
+   * </pre>
+   *
+   * @param name unique name of the metric.
+   * @param valueClass type of value recorded by the metric.
+   * @param desc description of the metric.
+   * @param trigger function to compute the value of the metric.
+   */
+  public <V> void newCallbackMetric(String name,
+      Class<V> valueClass, Description desc, final Supplier<V> trigger) {
+    final CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
+    newTrigger(metric, new Runnable() {
+      @Override
+      public void run() {
+        metric.set(trigger.get());
+      }
+    });
+  }
+
+  /** Instantaneous reading of a single value. */
+  public abstract <V> CallbackMetric0<V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc);
+  public abstract <F1, V> CallbackMetric1<F1, V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc,
+      Field<F1> field1);
+
+  /** Connect logic to populate a previously created {@link CallbackMetric}. */
+  public RegistrationHandle newTrigger(CallbackMetric<?> metric1, Runnable trigger) {
+    return newTrigger(ImmutableSet.<CallbackMetric<?>>of(metric1), trigger);
+  }
+
+  public RegistrationHandle newTrigger(CallbackMetric<?> metric1,
+      CallbackMetric<?> metric2, Runnable trigger) {
+    return newTrigger(ImmutableSet.of(metric1, metric2), trigger);
+  }
+
+  public RegistrationHandle newTrigger(CallbackMetric<?> metric1,
+      CallbackMetric<?> metric2, CallbackMetric<?> metric3, Runnable trigger) {
+    return newTrigger(ImmutableSet.of(metric1, metric2, metric3), trigger);
+  }
+
+  public abstract RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics,
+      Runnable trigger);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java
new file mode 100644
index 0000000..ff0735e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java
@@ -0,0 +1,54 @@
+// 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.metrics;
+
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Records elapsed time for an operation or span.
+ * <p>
+ * Typical usage in a try-with-resources block:
+ *
+ * <pre>
+ * try (Timer.Context ctx = timer.start()) {
+ * }
+ * </pre>
+ */
+public abstract class Timer0 implements RegistrationHandle {
+  public static class Context extends TimerContext {
+    private final Timer0 timer;
+
+    Context(Timer0 timer) {
+      this.timer = timer;
+    }
+
+    @Override
+    public void record(long elapsed) {
+      timer.record(elapsed, NANOSECONDS);
+    }
+  }
+
+  /** Begin a timer for the current block, value will be recorded when closed. */
+  public Context start() {
+    return new Context(this);
+  }
+
+  /** Record a value in the distribution. */
+  public abstract void record(long value, TimeUnit unit);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java
new file mode 100644
index 0000000..e0bc4fc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java
@@ -0,0 +1,59 @@
+// 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.metrics;
+
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Records elapsed time for an operation or span.
+ * <p>
+ * Typical usage in a try-with-resources block:
+ *
+ * <pre>
+ * try (Timer1.Context ctx = timer.start(field)) {
+ * }
+ * </pre>
+ *
+ * @param <F1> type of the field.
+ */
+public abstract class Timer1<F1> implements RegistrationHandle {
+  public static class Context extends TimerContext {
+    private final Timer1<Object> timer;
+    private final Object field1;
+
+    @SuppressWarnings("unchecked")
+    <F1> Context(Timer1<F1> timer, F1 field1) {
+      this.timer = (Timer1<Object>) timer;
+      this.field1 = field1;
+    }
+
+    @Override
+    public void record(long elapsed) {
+      timer.record(field1, elapsed, NANOSECONDS);
+    }
+  }
+
+  /** Begin a timer for the current block, value will be recorded when closed. */
+  public Context start(F1 field1) {
+    return new Context(this, field1);
+  }
+
+  /** Record a value in the distribution. */
+  public abstract void record(F1 field1, long value, TimeUnit unit);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java
new file mode 100644
index 0000000..5bfaba0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java
@@ -0,0 +1,62 @@
+// 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.metrics;
+
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Records elapsed time for an operation or span.
+ * <p>
+ * Typical usage in a try-with-resources block:
+ *
+ * <pre>
+ * try (Timer2.Context ctx = timer.start(field)) {
+ * }
+ * </pre>
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ */
+public abstract class Timer2<F1, F2> implements RegistrationHandle {
+  public static class Context extends TimerContext {
+    private final Timer2<Object, Object> timer;
+    private final Object field1;
+    private final Object field2;
+
+    @SuppressWarnings("unchecked")
+    <F1, F2> Context(Timer2<F1, F2> timer, F1 field1, F2 field2) {
+      this.timer = (Timer2<Object, Object>) timer;
+      this.field1 = field1;
+      this.field2 = field2;
+    }
+
+    @Override
+    public void record(long elapsed) {
+      timer.record(field1, field2, elapsed, NANOSECONDS);
+    }
+  }
+
+  /** Begin a timer for the current block, value will be recorded when closed. */
+  public Context start(F1 field1, F2 field2) {
+    return new Context(this, field1, field2);
+  }
+
+  /** Record a value in the distribution. */
+  public abstract void record(F1 field1, F2 field2, long value, TimeUnit unit);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java
new file mode 100644
index 0000000..c564d42
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java
@@ -0,0 +1,66 @@
+// 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.metrics;
+
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Records elapsed time for an operation or span.
+ * <p>
+ * Typical usage in a try-with-resources block:
+ *
+ * <pre>
+ * try (Timer3.Context ctx = timer.start(field)) {
+ * }
+ * </pre>
+ *
+ * @param <F1> type of the field.
+ * @param <F2> type of the field.
+ * @param <F3> type of the field.
+ */
+public abstract class Timer3<F1, F2, F3> implements RegistrationHandle {
+  public static class Context extends TimerContext {
+    private final Timer3<Object, Object, Object> timer;
+    private final Object field1;
+    private final Object field2;
+    private final Object field3;
+
+    @SuppressWarnings("unchecked")
+    <F1, F2, F3> Context(Timer3<F1, F2, F3> timer, F1 f1, F2 f2, F3 f3) {
+      this.timer = (Timer3<Object, Object, Object>) timer;
+      this.field1 = f1;
+      this.field2 = f2;
+      this.field3 = f3;
+    }
+
+    @Override
+    public void record(long elapsed) {
+      timer.record(field1, field2, field3, elapsed, NANOSECONDS);
+    }
+  }
+
+  /** Begin a timer for the current block, value will be recorded when closed. */
+  public Context start(F1 field1, F2 field2, F3 field3) {
+    return new Context(this, field1, field2, field3);
+  }
+
+  /** Record a value in the distribution. */
+  public abstract void record(F1 field1, F2 field2, F3 field3,
+      long value, TimeUnit unit);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/TimerContext.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/TimerContext.java
new file mode 100644
index 0000000..32ef753
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/TimerContext.java
@@ -0,0 +1,59 @@
+// 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.metrics;
+
+abstract class TimerContext implements AutoCloseable {
+  private final long startNanos;
+  private boolean stopped;
+
+  TimerContext() {
+    this.startNanos = System.nanoTime();
+  }
+
+  /**
+   * Record the elapsed time to the timer.
+   *
+   * @param elapsed Elapsed time in nanoseconds.
+   */
+  public abstract void record(long elapsed);
+
+  /** Get the start time in system time nanoseconds. */
+  public long getStartTime() {
+    return startNanos;
+  }
+
+  /**
+   * Stop the timer and record the elapsed time.
+   *
+   * @return the elapsed time in nanoseconds.
+   * @throws IllegalStateException if the timer is already stopped.
+   */
+  public long stop() {
+    if (!stopped) {
+      stopped = true;
+      long elapsed = System.nanoTime() - startNanos;
+      record(elapsed);
+      return elapsed;
+    }
+    throw new IllegalStateException("Already stopped");
+  }
+
+  @Override
+  public void close() {
+    if (!stopped) {
+      stop();
+    }
+  }
+}
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
new file mode 100644
index 0000000..629cf96
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
@@ -0,0 +1,149 @@
+// 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.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;
+import com.google.gerrit.metrics.Field;
+
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricRegistry;
+
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Abstract callback metric broken down into buckets. */
+abstract class BucketedCallback<V> implements BucketedMetric {
+  private final DropWizardMetricMaker metrics;
+  private final MetricRegistry registry;
+  private final String name;
+  private final Description.FieldOrdering ordering;
+  protected final Field<?>[] fields;
+  private final V zero;
+  private final Map<Object, ValueGauge> cells;
+  protected volatile Runnable trigger;
+
+  BucketedCallback(DropWizardMetricMaker metrics, MetricRegistry registry,
+      String name, Class<V> valueType, Description desc, Field<?>... fields) {
+    this.metrics = metrics;
+    this.registry = registry;
+    this.name = name;
+    this.ordering = desc.getFieldOrdering();
+    this.fields = fields;
+    this.zero = CallbackMetricImpl0.zeroFor(valueType);
+    this.cells = new ConcurrentHashMap<>();
+  }
+
+  void doRemove() {
+    for (Object key : cells.keySet()) {
+      registry.remove(submetric(key));
+    }
+    metrics.remove(name);
+  }
+
+  void doBeginSet() {
+    for (ValueGauge g : cells.values()) {
+      g.set = false;
+    }
+  }
+
+  void doPrune() {
+    Iterator<Map.Entry<Object, ValueGauge>> i = cells.entrySet().iterator();
+    while (i.hasNext()) {
+      if (!i.next().getValue().set) {
+        i.remove();
+      }
+    }
+  }
+
+  void doEndSet() {
+    for (ValueGauge g : cells.values()) {
+      if (!g.set) {
+        g.value = zero;
+      }
+    }
+  }
+
+  ValueGauge getOrCreate(Object f1, Object f2) {
+    return getOrCreate(ImmutableList.of(f1, f2));
+  }
+
+  ValueGauge getOrCreate(Object f1, Object f2, Object f3) {
+    return getOrCreate(ImmutableList.of(f1, f2, f3));
+  }
+
+  ValueGauge getOrCreate(Object key) {
+    ValueGauge c = cells.get(key);
+    if (c != null) {
+      return c;
+    }
+
+    synchronized (cells) {
+      c = cells.get(key);
+      if (c == null) {
+        c = new ValueGauge();
+        registry.register(submetric(key), c);
+        cells.put(key, c);
+      }
+      return c;
+    }
+  }
+
+  private String submetric(Object key) {
+    return DropWizardMetricMaker.name(ordering, name, name(key));
+  }
+
+  abstract String name(Object key);
+
+  @Override
+  public Metric getTotal() {
+    return null;
+  }
+
+  @Override
+  public Field<?>[] getFields() {
+    return fields;
+  }
+
+  @Override
+  public Map<Object, Metric> getCells() {
+    return Maps.transformValues(
+        cells,
+        new Function<ValueGauge, Metric> () {
+          @Override
+          public Metric apply(ValueGauge in) {
+            return in;
+          }
+        });
+  }
+
+  final class ValueGauge implements Gauge<V> {
+    volatile V value = zero;
+    boolean set;
+
+    @Override
+    public V getValue() {
+      Runnable t = trigger;
+      if (t != null) {
+        t.run();
+      }
+      return value;
+    }
+  }
+}
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
new file mode 100644
index 0000000..22af5ca
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
@@ -0,0 +1,109 @@
+// 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.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;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker.CounterImpl;
+
+import com.codahale.metrics.Metric;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Abstract counter broken down into buckets by {@link Field} values. */
+abstract class BucketedCounter implements BucketedMetric {
+  private final DropWizardMetricMaker metrics;
+  private final String name;
+  private final boolean isRate;
+  private final Description.FieldOrdering ordering;
+  protected final Field<?>[] fields;
+  protected final CounterImpl total;
+  private final Map<Object, CounterImpl> cells;
+
+  BucketedCounter(DropWizardMetricMaker metrics,
+      String name, Description desc, Field<?>... fields) {
+    this.metrics = metrics;
+    this.name = name;
+    this.isRate = desc.isRate();
+    this.ordering = desc.getFieldOrdering();
+    this.fields = fields;
+    this.total = metrics.newCounterImpl(name + "_total", isRate);
+    this.cells = new ConcurrentHashMap<>();
+  }
+
+  void doRemove() {
+    for (CounterImpl c : cells.values()) {
+      c.remove();
+    }
+    total.remove();
+    metrics.remove(name);
+  }
+
+  CounterImpl forceCreate(Object f1, Object f2) {
+    return forceCreate(ImmutableList.of(f1, f2));
+  }
+
+  CounterImpl forceCreate(Object f1, Object f2, Object f3) {
+    return forceCreate(ImmutableList.of(f1, f2, f3));
+  }
+
+  CounterImpl forceCreate(Object key) {
+    CounterImpl c = cells.get(key);
+    if (c != null) {
+      return c;
+    }
+
+    synchronized (cells) {
+      c = cells.get(key);
+      if (c == null) {
+        c = metrics.newCounterImpl(submetric(key), isRate);
+        cells.put(key, c);
+      }
+      return c;
+    }
+  }
+
+  private String submetric(Object key) {
+    return DropWizardMetricMaker.name(ordering, name, name(key));
+  }
+
+  abstract String name(Object key);
+
+  @Override
+  public Metric getTotal() {
+    return total.metric;
+  }
+
+  @Override
+  public Field<?>[] getFields() {
+    return fields;
+  }
+
+  @Override
+  public Map<Object, Metric> getCells() {
+    return Maps.transformValues(
+        cells,
+        new Function<CounterImpl, Metric> () {
+          @Override
+          public Metric apply(CounterImpl in) {
+            return in.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
new file mode 100644
index 0000000..51c5fea
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
@@ -0,0 +1,107 @@
+// 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.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;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker.HistogramImpl;
+
+import com.codahale.metrics.Metric;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Abstract histogram broken down into buckets by {@link Field} values. */
+abstract class BucketedHistogram implements BucketedMetric {
+  private final DropWizardMetricMaker metrics;
+  private final String name;
+  private final Description.FieldOrdering ordering;
+  protected final Field<?>[] fields;
+  protected final HistogramImpl total;
+  private final Map<Object, HistogramImpl> cells;
+
+  BucketedHistogram(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<?>... fields) {
+    this.metrics = metrics;
+    this.name = name;
+    this.ordering = desc.getFieldOrdering();
+    this.fields = fields;
+    this.total = metrics.newHistogramImpl(name + "_total");
+    this.cells = new ConcurrentHashMap<>();
+  }
+
+  void doRemove() {
+    for (HistogramImpl c : cells.values()) {
+      c.remove();
+    }
+    total.remove();
+    metrics.remove(name);
+  }
+
+  HistogramImpl forceCreate(Object f1, Object f2) {
+    return forceCreate(ImmutableList.of(f1, f2));
+  }
+
+  HistogramImpl forceCreate(Object f1, Object f2, Object f3) {
+    return forceCreate(ImmutableList.of(f1, f2, f3));
+  }
+
+  HistogramImpl forceCreate(Object key) {
+    HistogramImpl c = cells.get(key);
+    if (c != null) {
+      return c;
+    }
+
+    synchronized (cells) {
+      c = cells.get(key);
+      if (c == null) {
+        c = metrics.newHistogramImpl(submetric(key));
+        cells.put(key, c);
+      }
+      return c;
+    }
+  }
+
+  private String submetric(Object key) {
+    return DropWizardMetricMaker.name(ordering, name, name(key));
+  }
+
+  abstract String name(Object key);
+
+  @Override
+  public Metric getTotal() {
+    return total.metric;
+  }
+
+  @Override
+  public Field<?>[] getFields() {
+    return fields;
+  }
+
+  @Override
+  public Map<Object, Metric> getCells() {
+    return Maps.transformValues(
+        cells,
+        new Function<HistogramImpl, Metric> () {
+          @Override
+          public Metric apply(HistogramImpl in) {
+            return in.metric;
+          }
+        });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java
new file mode 100644
index 0000000..799e594
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java
@@ -0,0 +1,29 @@
+// 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.metrics.dropwizard;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Field;
+
+import com.codahale.metrics.Metric;
+
+import java.util.Map;
+
+/** Metric broken down into buckets by {@link Field} values. */
+interface BucketedMetric extends Metric {
+  @Nullable Metric getTotal();
+  Field<?>[] getFields();
+  Map<?, Metric> getCells();
+}
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
new file mode 100644
index 0000000..ec12e00
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
@@ -0,0 +1,107 @@
+// 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.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;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker.TimerImpl;
+
+import com.codahale.metrics.Metric;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Abstract timer broken down into buckets by {@link Field} values. */
+abstract class BucketedTimer implements BucketedMetric {
+  private final DropWizardMetricMaker metrics;
+  private final String name;
+  private final Description.FieldOrdering ordering;
+  protected final Field<?>[] fields;
+  protected final TimerImpl total;
+  private final Map<Object, TimerImpl> cells;
+
+  BucketedTimer(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<?>... fields) {
+    this.metrics = metrics;
+    this.name = name;
+    this.ordering = desc.getFieldOrdering();
+    this.fields = fields;
+    this.total = metrics.newTimerImpl(name + "_total");
+    this.cells = new ConcurrentHashMap<>();
+  }
+
+  void doRemove() {
+    for (TimerImpl c : cells.values()) {
+      c.remove();
+    }
+    total.remove();
+    metrics.remove(name);
+  }
+
+  TimerImpl forceCreate(Object f1, Object f2) {
+    return forceCreate(ImmutableList.of(f1, f2));
+  }
+
+  TimerImpl forceCreate(Object f1, Object f2, Object f3) {
+    return forceCreate(ImmutableList.of(f1, f2, f3));
+  }
+
+  TimerImpl forceCreate(Object key) {
+    TimerImpl c = cells.get(key);
+    if (c != null) {
+      return c;
+    }
+
+    synchronized (cells) {
+      c = cells.get(key);
+      if (c == null) {
+        c = metrics.newTimerImpl(submetric(key));
+        cells.put(key, c);
+      }
+      return c;
+    }
+  }
+
+  private String submetric(Object key) {
+    return DropWizardMetricMaker.name(ordering, name, name(key));
+  }
+
+  abstract String name(Object key);
+
+  @Override
+  public Metric getTotal() {
+    return total.metric;
+  }
+
+  @Override
+  public Field<?>[] getFields() {
+    return fields;
+  }
+
+  @Override
+  public Map<Object, Metric> getCells() {
+    return Maps.transformValues(
+        cells,
+        new Function<TimerImpl, Metric> () {
+          @Override
+          public Metric apply(TimerImpl in) {
+            return in.metric;
+          }
+        });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java
new file mode 100644
index 0000000..372bdcb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java
@@ -0,0 +1,70 @@
+// 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.metrics.dropwizard;
+
+import com.google.common.collect.ImmutableSet;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Run a user specified trigger only once every 2 seconds.
+ * <p>
+ * This allows the same Runnable trigger to be applied to several metrics. When
+ * a recorder is sampling the related metrics only the first access will perform
+ * recomputation. Reading other related metrics will rely on the already set
+ * values for the next several seconds.
+ */
+class CallbackGroup implements Runnable {
+  private static final long PERIOD = TimeUnit.SECONDS.toNanos(2);
+
+  private final AtomicLong reloadAt;
+  private final Runnable trigger;
+  private final ImmutableSet<CallbackMetricGlue> metrics;
+  private final Object reloadLock = new Object();
+
+  CallbackGroup(Runnable trigger, ImmutableSet<CallbackMetricGlue> metrics) {
+    this.reloadAt = new AtomicLong(0);
+    this.trigger = trigger;
+    this.metrics = metrics;
+  }
+
+  @Override
+  public void run() {
+    if (reload()) {
+      synchronized (reloadLock) {
+        for (CallbackMetricGlue m : metrics) {
+          m.beginSet();
+        }
+        trigger.run();
+        for (CallbackMetricGlue m : metrics) {
+          m.endSet();
+        }
+      }
+    }
+  }
+
+  private boolean reload() {
+    for (;;) {
+      long now = System.nanoTime();
+      long next = reloadAt.get();
+      if (next > now) {
+        return false;
+      } else if (reloadAt.compareAndSet(next, now + PERIOD)) {
+        return true;
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java
new file mode 100644
index 0000000..4f5b7ad
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java
@@ -0,0 +1,22 @@
+// 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.metrics.dropwizard;
+
+interface CallbackMetricGlue {
+  void beginSet();
+  void endSet();
+  void register(Runnable trigger);
+  void remove();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
new file mode 100644
index 0000000..dcab692
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
@@ -0,0 +1,86 @@
+// 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.metrics.dropwizard;
+
+import com.google.gerrit.metrics.CallbackMetric0;
+
+import com.codahale.metrics.MetricRegistry;
+
+class CallbackMetricImpl0<V>
+    extends CallbackMetric0<V>
+    implements CallbackMetricGlue {
+  @SuppressWarnings("unchecked")
+  static <V> V zeroFor(Class<V> valueClass) {
+    if (valueClass == Integer.class) {
+      return (V) Integer.valueOf(0);
+    } else if (valueClass == Long.class) {
+      return (V) Long.valueOf(0);
+    } else if (valueClass == Double.class) {
+      return (V) Double.valueOf(0);
+    } else if (valueClass == Float.class) {
+      return (V) Float.valueOf(0);
+    } else if (valueClass == String.class) {
+      return (V) "";
+    } else if (valueClass == Boolean.class) {
+      return (V) Boolean.FALSE;
+    } else {
+      throw new IllegalArgumentException("unsupported value type "
+          + valueClass.getName());
+    }
+  }
+
+  private final DropWizardMetricMaker metrics;
+  private final MetricRegistry registry;
+  private final String name;
+  private volatile V value;
+
+  CallbackMetricImpl0(DropWizardMetricMaker metrics, MetricRegistry registry,
+      String name, Class<V> valueType) {
+    this.metrics = metrics;
+    this.registry = registry;
+    this.name = name;
+    this.value = zeroFor(valueType);
+  }
+
+  @Override
+  public void beginSet() {
+  }
+
+  @Override
+  public void endSet() {
+  }
+
+  @Override
+  public void set(V value) {
+    this.value = value;
+  }
+
+  @Override
+  public void remove() {
+    metrics.remove(name);
+    registry.remove(name);
+  }
+
+  @Override
+  public void register(final Runnable trigger) {
+    registry.register(name, new com.codahale.metrics.Gauge<V>() {
+      @Override
+      public V getValue() {
+        trigger.run();
+        return value;
+      }
+    });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
new file mode 100644
index 0000000..81d5ff5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
@@ -0,0 +1,84 @@
+// 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.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+
+import com.codahale.metrics.MetricRegistry;
+
+/** Optimized version of {@link BucketedCallback} for single dimension. */
+class CallbackMetricImpl1<F1, V> extends BucketedCallback<V> {
+  CallbackMetricImpl1(DropWizardMetricMaker metrics, MetricRegistry registry,
+      String name, Class<V> valueClass, Description desc, Field<F1> field1) {
+    super(metrics, registry, name, valueClass, desc, field1);
+  }
+
+  CallbackMetric1<F1, V> create() {
+    return new Impl1();
+  }
+
+  private final class Impl1
+      extends CallbackMetric1<F1, V>
+      implements CallbackMetricGlue {
+    @Override
+    public void beginSet() {
+      doBeginSet();
+    }
+
+    @Override
+    public void set(F1 field1, V value) {
+      BucketedCallback<V>.ValueGauge cell = getOrCreate(field1);
+      cell.value = value;
+      cell.set = true;
+    }
+
+    @Override
+    public void prune() {
+      doPrune();
+    }
+
+    @Override
+    public void endSet() {
+      doEndSet();
+    }
+
+    @Override
+    public void forceCreate(F1 field1) {
+      getOrCreate(field1);
+    }
+
+    @Override
+    public void register(Runnable t) {
+      trigger = t;
+    }
+
+    @Override
+    public void remove() {
+      doRemove();
+    }
+  }
+
+  @Override
+  String name(Object field1) {
+    @SuppressWarnings("unchecked")
+    Function<Object, String> fmt =
+        (Function<Object, String>) fields[0].formatter();
+
+    return fmt.apply(field1).replace('/', '-');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
new file mode 100644
index 0000000..25647ef
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
@@ -0,0 +1,52 @@
+// 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.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+
+/** Optimized version of {@link BucketedCounter} for single dimension. */
+class CounterImpl1<F1> extends BucketedCounter {
+  CounterImpl1(DropWizardMetricMaker metrics, String name, Description desc,
+      Field<F1> field1) {
+    super(metrics, name, desc, field1);
+  }
+
+  Counter1<F1> counter() {
+    return new Counter1<F1>() {
+      @Override
+      public void incrementBy(F1 field1, long value) {
+        total.incrementBy(value);
+        forceCreate(field1).incrementBy(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @Override
+  String name(Object field1) {
+    @SuppressWarnings("unchecked")
+    Function<Object, String> fmt =
+        (Function<Object, String>) fields[0].formatter();
+
+    return fmt.apply(field1).replace('/', '-');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
new file mode 100644
index 0000000..a2f1f84
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
@@ -0,0 +1,75 @@
+// 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.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+
+/** Generalized implementation of N-dimensional counter metrics. */
+class CounterImplN extends BucketedCounter implements BucketedMetric {
+  CounterImplN(DropWizardMetricMaker metrics, String name, Description desc,
+      Field<?>... fields) {
+    super(metrics, name, desc, fields);
+  }
+
+  <F1, F2> Counter2<F1, F2> counter2() {
+    return new Counter2<F1, F2>() {
+      @Override
+      public void incrementBy(F1 field1, F2 field2, long value) {
+        total.incrementBy(value);
+        forceCreate(field1, field2).incrementBy(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  <F1, F2, F3> Counter3<F1, F2, F3> counter3() {
+    return new Counter3<F1, F2, F3>() {
+      @Override
+      public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {
+        total.incrementBy(value);
+        forceCreate(field1, field2, field3).incrementBy(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  String name(Object key) {
+    ImmutableList<Object> keyList = (ImmutableList<Object>) key;
+    String[] parts = new String[fields.length];
+    for (int i = 0; i < fields.length; i++) {
+      Function<Object, String> fmt =
+          (Function<Object, String>) fields[i].formatter();
+
+      parts[i] = fmt.apply(keyList.get(i)).replace('/', '-');
+    }
+    return Joiner.on('/').join(parts);
+  }
+}
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
new file mode 100644
index 0000000..8f6e5d3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
@@ -0,0 +1,428 @@
+// 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.metrics.dropwizard;
+
+import static com.google.common.base.Preconditions.checkArgument;
+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;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.metrics.CallbackMetric;
+import com.google.gerrit.metrics.CallbackMetric0;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.FieldOrdering;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram0;
+import com.google.gerrit.metrics.Histogram1;
+import com.google.gerrit.metrics.Histogram2;
+import com.google.gerrit.metrics.Histogram3;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.metrics.proc.JGitMetricModule;
+import com.google.gerrit.metrics.proc.ProcMetricModule;
+import com.google.gerrit.server.cache.CacheMetrics;
+import com.google.inject.Inject;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricRegistry;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+/**
+ * Connects Gerrit metric package onto DropWizard.
+ *
+ * @see <a href="http://www.dropwizard.io/">DropWizard</a>
+ */
+@Singleton
+public class DropWizardMetricMaker extends MetricMaker {
+  public static class ApiModule extends RestApiModule {
+    @Override
+    protected void configure() {
+      bind(MetricRegistry.class).in(Scopes.SINGLETON);
+      bind(DropWizardMetricMaker.class).in(Scopes.SINGLETON);
+      bind(MetricMaker.class).to(DropWizardMetricMaker.class);
+
+      install(new ProcMetricModule());
+      install(new JGitMetricModule());
+    }
+  }
+
+  public static class RestModule extends RestApiModule {
+    @Override
+    protected void configure() {
+      DynamicMap.mapOf(binder(), METRIC_KIND);
+      child(CONFIG_KIND, "metrics").to(MetricsCollection.class);
+      get(METRIC_KIND).to(GetMetric.class);
+      bind(CacheMetrics.class);
+    }
+  }
+
+  private final MetricRegistry registry;
+  private final Map<String, BucketedMetric> bucketed;
+  private final Map<String, ImmutableMap<String, String>> descriptions;
+
+  @Inject
+  DropWizardMetricMaker(MetricRegistry registry) {
+    this.registry = registry;
+    this.bucketed = new ConcurrentHashMap<>();
+    this.descriptions = new ConcurrentHashMap<>();
+  }
+
+  Iterable<String> getMetricNames() {
+    return descriptions.keySet();
+  }
+
+  /** Get the underlying metric implementation. */
+  public Metric getMetric(String name) {
+    Metric m = bucketed.get(name);
+    return m != null ? m : registry.getMetrics().get(name);
+  }
+
+  /** Lookup annotations from a metric's {@link Description}. */
+  public ImmutableMap<String, String> getAnnotations(String name) {
+    return descriptions.get(name);
+  }
+
+  @Override
+  public synchronized Counter0 newCounter(String name, Description desc) {
+    checkCounterDescription(name, desc);
+    define(name, desc);
+    return newCounterImpl(name, desc.isRate());
+  }
+
+  @Override
+  public synchronized <F1> Counter1<F1> newCounter(
+      String name, Description desc,
+      Field<F1> field1) {
+    checkCounterDescription(name, desc);
+    CounterImpl1<F1> m = new CounterImpl1<>(this, name, desc, field1);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.counter();
+  }
+
+  @Override
+  public synchronized <F1, F2> Counter2<F1, F2> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    checkCounterDescription(name, desc);
+    CounterImplN m = new CounterImplN(this, name, desc, field1, field2);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.counter2();
+  }
+
+  @Override
+  public synchronized <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    checkCounterDescription(name, desc);
+    CounterImplN m = new CounterImplN(this, name, desc, field1, field2, field3);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.counter3();
+  }
+
+  private static void checkCounterDescription(String name, Description desc) {
+    checkMetricName(name);
+    checkArgument(!desc.isConstant(), "counters must not be constant");
+    checkArgument(!desc.isGauge(), "counters must not be gauge");
+  }
+
+  CounterImpl newCounterImpl(String name, boolean isRate) {
+    if (isRate) {
+      final com.codahale.metrics.Meter m = registry.meter(name);
+      return new CounterImpl(name, m) {
+        @Override
+        public void incrementBy(long delta) {
+          checkArgument(delta >= 0, "counter delta must be >= 0");
+          m.mark(delta);
+        }
+      };
+    } else {
+      final com.codahale.metrics.Counter m = registry.counter(name);
+      return new CounterImpl(name, m) {
+        @Override
+        public void incrementBy(long delta) {
+          checkArgument(delta >= 0, "counter delta must be >= 0");
+          m.inc(delta);
+        }
+      };
+    }
+  }
+
+  @Override
+  public synchronized Timer0 newTimer(String name, Description desc) {
+    checkTimerDescription(name, desc);
+    define(name, desc);
+    return newTimerImpl(name);
+  }
+
+  @Override
+  public synchronized <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
+    checkTimerDescription(name, desc);
+    TimerImpl1<F1> m = new TimerImpl1<>(this, name, desc, field1);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.timer();
+  }
+
+  @Override
+  public synchronized <F1, F2> Timer2<F1, F2> newTimer(String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    checkTimerDescription(name, desc);
+    TimerImplN m = new TimerImplN(this, name, desc, field1, field2);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.timer2();
+  }
+
+  @Override
+  public synchronized <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    checkTimerDescription(name, desc);
+    TimerImplN m = new TimerImplN(this, name, desc, field1, field2, field3);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.timer3();
+  }
+
+  private static void checkTimerDescription(String name, Description desc) {
+    checkMetricName(name);
+    checkArgument(!desc.isConstant(), "timer must not be constant");
+    checkArgument(!desc.isGauge(), "timer must not be a gauge");
+    checkArgument(!desc.isRate(), "timer must not be a rate");
+    checkArgument(desc.isCumulative(), "timer must be cumulative");
+    checkArgument(desc.getTimeUnit() != null, "timer must have a unit");
+  }
+
+  TimerImpl newTimerImpl(String name) {
+    return new TimerImpl(name, registry.timer(name));
+  }
+
+  @Override
+  public synchronized Histogram0 newHistogram(String name, Description desc) {
+    checkHistogramDescription(name, desc);
+    define(name, desc);
+    return newHistogramImpl(name);
+  }
+
+  @Override
+  public synchronized <F1> Histogram1<F1> newHistogram(String name,
+      Description desc, Field<F1> field1) {
+    checkHistogramDescription(name, desc);
+    HistogramImpl1<F1> m = new HistogramImpl1<>(this, name, desc, field1);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.histogram1();
+  }
+
+  @Override
+  public synchronized <F1, F2> Histogram2<F1, F2> newHistogram(String name,
+      Description desc, Field<F1> field1, Field<F2> field2) {
+    checkHistogramDescription(name, desc);
+    HistogramImplN m = new HistogramImplN(this, name, desc, field1, field2);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.histogram2();
+  }
+
+  @Override
+  public synchronized <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    checkHistogramDescription(name, desc);
+    HistogramImplN m = new HistogramImplN(this, name, desc, field1, field2, field3);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.histogram3();
+  }
+
+  private static void checkHistogramDescription(String name, Description desc) {
+    checkMetricName(name);
+    checkArgument(!desc.isConstant(), "histogram must not be constant");
+    checkArgument(!desc.isGauge(), "histogram must not be a gauge");
+    checkArgument(!desc.isRate(), "histogram must not be a rate");
+    checkArgument(desc.isCumulative(), "histogram must be cumulative");
+  }
+
+  HistogramImpl newHistogramImpl(String name) {
+    return new HistogramImpl(name, registry.histogram(name));
+  }
+
+  @Override
+  public <V> CallbackMetric0<V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc) {
+    checkMetricName(name);
+    define(name, desc);
+    return new CallbackMetricImpl0<>(this, registry, name, valueClass);
+  }
+
+  @Override
+  public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc, Field<F1> field1) {
+    checkMetricName(name);
+    CallbackMetricImpl1<F1, V> m = new CallbackMetricImpl1<>(this, registry,
+        name, valueClass, desc, field1);
+    define(name, desc);
+    bucketed.put(name, m);
+    return m.create();
+  }
+
+  @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;
+            }
+          })
+        .toSet();
+
+    trigger = new CallbackGroup(trigger, all);
+    for (CallbackMetricGlue m : all) {
+      m.register(trigger);
+    }
+    trigger.run();
+
+    return new RegistrationHandle() {
+      @Override
+      public void remove() {
+        for (CallbackMetricGlue m : all) {
+          m.remove();
+        }
+      }
+    };
+  }
+
+  synchronized void remove(String name) {
+    bucketed.remove(name);
+    descriptions.remove(name);
+  }
+
+  private synchronized void define(String name, Description desc) {
+    if (descriptions.containsKey(name)) {
+      throw new IllegalStateException(String.format(
+          "metric %s already defined", name));
+    }
+    descriptions.put(name, desc.getAnnotations());
+  }
+
+  private static final Pattern METRIC_NAME_PATTERN = Pattern
+      .compile("[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*");
+
+  private static void checkMetricName(String name) {
+    checkArgument(
+        METRIC_NAME_PATTERN.matcher(name).matches(),
+        "metric name must match %s", METRIC_NAME_PATTERN.pattern());
+  }
+
+  static String name(Description.FieldOrdering ordering,
+      String codeName,
+      String fieldValues) {
+    if (ordering == FieldOrdering.PREFIX_FIELDS_BASENAME) {
+      int s = codeName.lastIndexOf('/');
+      if (s > 0) {
+        String prefix = codeName.substring(0, s);
+        String metric = codeName.substring(s + 1);
+        return prefix + '/' + fieldValues + '/' + metric;
+      }
+    }
+    return codeName + '/' + fieldValues;
+  }
+
+  abstract class CounterImpl extends Counter0 {
+    private final String name;
+    final Metric metric;
+
+    CounterImpl(String name, Metric metric) {
+      this.name = name;
+      this.metric = metric;
+    }
+
+    @Override
+    public void remove() {
+      descriptions.remove(name);
+      registry.remove(name);
+    }
+  }
+
+  class TimerImpl extends Timer0 {
+    private final String name;
+    final com.codahale.metrics.Timer metric;
+
+    private TimerImpl(String name, com.codahale.metrics.Timer metric) {
+      this.name = name;
+      this.metric = metric;
+    }
+
+    @Override
+    public void record(long value, TimeUnit unit) {
+      checkArgument(value >= 0, "timer delta must be >= 0");
+      metric.update(value, unit);
+    }
+
+    @Override
+    public void remove() {
+      descriptions.remove(name);
+      registry.remove(name);
+    }
+  }
+
+  class HistogramImpl extends Histogram0 {
+    private final String name;
+    final com.codahale.metrics.Histogram metric;
+
+    private HistogramImpl(String name, com.codahale.metrics.Histogram metric) {
+      this.name = name;
+      this.metric = metric;
+    }
+
+    @Override
+    public void record(long value) {
+      metric.update(value);
+    }
+
+    @Override
+    public void remove() {
+      descriptions.remove(name);
+      registry.remove(name);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
new file mode 100644
index 0000000..47064df
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
@@ -0,0 +1,47 @@
+// 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.metrics.dropwizard;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.inject.Inject;
+
+import org.kohsuke.args4j.Option;
+
+class GetMetric implements RestReadView<MetricResource> {
+  private final CurrentUser user;
+  private final DropWizardMetricMaker metrics;
+
+  @Option(name = "--data-only", usage = "return only values")
+  boolean dataOnly;
+
+  @Inject
+  GetMetric(CurrentUser user, DropWizardMetricMaker metrics) {
+    this.user = user;
+    this.metrics = metrics;
+  }
+
+  @Override
+  public MetricJson apply(MetricResource resource) throws AuthException {
+    if (!user.getCapabilities().canViewCaches()) {
+      throw new AuthException("restricted to viewCaches");
+    }
+    return new MetricJson(
+        resource.getMetric(),
+        metrics.getAnnotations(resource.getName()),
+        dataOnly);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
new file mode 100644
index 0000000..e3f9e1c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
@@ -0,0 +1,52 @@
+// 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.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram1;
+
+/** Optimized version of {@link BucketedHistogram} for single dimension. */
+class HistogramImpl1<F1> extends BucketedHistogram implements BucketedMetric {
+  HistogramImpl1(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<F1> field1) {
+    super(metrics, name, desc, field1);
+  }
+
+  Histogram1<F1> histogram1() {
+    return new Histogram1<F1>() {
+      @Override
+      public void record(F1 field1, long value) {
+        total.record(value);
+        forceCreate(field1).record(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @Override
+  String name(Object field1) {
+    @SuppressWarnings("unchecked")
+    Function<Object, String> fmt =
+        (Function<Object, String>) fields[0].formatter();
+
+    return fmt.apply(field1).replace('/', '-');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
new file mode 100644
index 0000000..d832c60
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
@@ -0,0 +1,75 @@
+// 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.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram2;
+import com.google.gerrit.metrics.Histogram3;
+
+/** Generalized implementation of N-dimensional Histogram metrics. */
+class HistogramImplN extends BucketedHistogram implements BucketedMetric {
+  HistogramImplN(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<?>... fields) {
+    super(metrics, name, desc, fields);
+  }
+
+  <F1, F2> Histogram2<F1, F2> histogram2() {
+    return new Histogram2<F1, F2>() {
+      @Override
+      public void record(F1 field1, F2 field2, long value) {
+        total.record(value);
+        forceCreate(field1, field2).record(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  <F1, F2, F3> Histogram3<F1, F2, F3> histogram3() {
+    return new Histogram3<F1, F2, F3>() {
+      @Override
+      public void record(F1 field1, F2 field2, F3 field3, long value) {
+        total.record(value);
+        forceCreate(field1, field2, field3).record(value);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  String name(Object key) {
+    ImmutableList<Object> keyList = (ImmutableList<Object>) key;
+    String[] parts = new String[fields.length];
+    for (int i = 0; i < fields.length; i++) {
+      Function<Object, String> fmt =
+          (Function<Object, String>) fields[i].formatter();
+
+      parts[i] = fmt.apply(keyList.get(i)).replace('/', '-');
+    }
+    return Joiner.on('/').join(parts);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
new file mode 100644
index 0000000..04d10a2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -0,0 +1,96 @@
+// 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.metrics.dropwizard;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Inject;
+
+import com.codahale.metrics.Metric;
+
+import org.kohsuke.args4j.Option;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+class ListMetrics implements RestReadView<ConfigResource> {
+  private final CurrentUser user;
+  private final DropWizardMetricMaker metrics;
+
+  @Option(name = "--data-only", usage = "return only values")
+  boolean dataOnly;
+
+  @Option(name = "--prefix", aliases = {"-p"}, metaVar = "PREFIX",
+      usage = "match metric by exact match or prefix")
+  List<String> query = new ArrayList<>();
+
+  @Inject
+  ListMetrics(CurrentUser user, DropWizardMetricMaker metrics) {
+    this.user = user;
+    this.metrics = metrics;
+  }
+
+  @Override
+  public Map<String, MetricJson> apply(ConfigResource resource)
+      throws AuthException {
+    if (!user.getCapabilities().canViewCaches()) {
+      throw new AuthException("restricted to viewCaches");
+    }
+
+    SortedMap<String, MetricJson> out = new TreeMap<>();
+    List<String> prefixes = new ArrayList<>(query.size());
+    for (String q : query) {
+      if (q.endsWith("/")) {
+        prefixes.add(q);
+      } else {
+        Metric m = metrics.getMetric(q);
+        if (m != null) {
+          out.put(q, toJson(q, m));
+        }
+      }
+    }
+
+    if (query.isEmpty() || !prefixes.isEmpty()) {
+      for (String name : metrics.getMetricNames()) {
+        if (include(prefixes, name)) {
+          out.put(name, toJson(name, metrics.getMetric(name)));
+        }
+      }
+    }
+
+    return out;
+  }
+
+  private MetricJson toJson(String q, Metric m) {
+    return new MetricJson(m, metrics.getAnnotations(q), dataOnly);
+  }
+
+  private static boolean include(List<String> prefixes, String name) {
+    if (prefixes.isEmpty()) {
+      return true;
+    }
+    for (String p : prefixes) {
+      if (name.startsWith(p)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
new file mode 100644
index 0000000..b332262
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
@@ -0,0 +1,210 @@
+// 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.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.Snapshot;
+import com.codahale.metrics.Timer;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+class MetricJson {
+  String description;
+  String unit;
+  Boolean constant;
+  Boolean rate;
+  Boolean gauge;
+  Boolean cumulative;
+
+  Long count;
+  Object value;
+
+  Double rate_1m;
+  Double rate_5m;
+  Double rate_15m;
+  Double rate_mean;
+
+  Double p50;
+  Double p75;
+  Double p95;
+  Double p98;
+  Double p99;
+  Double p99_9;
+
+  Double min;
+  Double avg;
+  Double max;
+  Double sum;
+  Double std_dev;
+
+  List<FieldJson> fields;
+  Map<String, Object> buckets;
+
+  MetricJson(Metric metric, ImmutableMap<String, String> atts, boolean dataOnly) {
+    if (!dataOnly) {
+      description = atts.get(Description.DESCRIPTION);
+      unit = atts.get(Description.UNIT);
+      constant = toBool(atts, Description.CONSTANT);
+      rate = toBool(atts, Description.RATE);
+      gauge = toBool(atts, Description.GAUGE);
+      cumulative = toBool(atts, Description.CUMULATIVE);
+    }
+    init(metric, atts);
+  }
+
+  private void init(Metric metric, ImmutableMap<String, String> atts) {
+    if (metric instanceof BucketedMetric) {
+      BucketedMetric m = (BucketedMetric) metric;
+      if (m.getTotal() != null) {
+        init(m.getTotal(), atts);
+      }
+
+      Field<?>[] fieldList = m.getFields();
+      fields = new ArrayList<>(fieldList.length);
+      for (Field<?> f : fieldList) {
+        fields.add(new FieldJson(f));
+      }
+      buckets = makeBuckets(fieldList, m.getCells(), atts);
+
+    } else if (metric instanceof Counter) {
+      Counter c = (Counter) metric;
+      count = c.getCount();
+
+    } else if (metric instanceof Gauge) {
+      Gauge<?> g = (Gauge<?>) metric;
+      value = g.getValue();
+
+    } else if (metric instanceof Meter) {
+      Meter m = (Meter) metric;
+      count = m.getCount();
+      rate_1m = m.getOneMinuteRate();
+      rate_5m = m.getFiveMinuteRate();
+      rate_15m = m.getFifteenMinuteRate();
+
+    } else if (metric instanceof Timer) {
+      Timer m = (Timer) metric;
+      Snapshot s = m.getSnapshot();
+      count = m.getCount();
+      rate_1m = m.getOneMinuteRate();
+      rate_5m = m.getFiveMinuteRate();
+      rate_15m = m.getFifteenMinuteRate();
+
+      double div =
+          Description.getTimeUnit(atts.get(Description.UNIT)).toNanos(1);
+      p50 = s.getMedian() / div;
+      p75 = s.get75thPercentile() / div;
+      p95 = s.get95thPercentile() / div;
+      p98 = s.get98thPercentile() / div;
+      p99 = s.get99thPercentile() / div;
+      p99_9 = s.get999thPercentile() / div;
+
+      min = s.getMin() / div;
+      max = s.getMax() / div;
+      std_dev = s.getStdDev() / div;
+
+    } else if (metric instanceof Histogram) {
+      Histogram m = (Histogram) metric;
+      Snapshot s = m.getSnapshot();
+      count = m.getCount();
+
+      p50 = s.getMedian();
+      p75 = s.get75thPercentile();
+      p95 = s.get95thPercentile();
+      p98 = s.get98thPercentile();
+      p99 = s.get99thPercentile();
+      p99_9 = s.get999thPercentile();
+
+      min = (double) s.getMin();
+      avg = (double) s.getMean();
+      max = (double) s.getMax();
+      sum = s.getMean() * m.getCount();
+      std_dev = s.getStdDev();
+    }
+  }
+
+  private static Boolean toBool(ImmutableMap<String, String> atts, String key) {
+    return Description.TRUE_VALUE.equals(atts.get(key)) ? true : null;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Map<String, Object> makeBuckets(
+      Field<?>[] fields,
+      Map<?, Metric> metrics,
+      ImmutableMap<String, String> atts) {
+    if (fields.length == 1) {
+      Function<Object, String> fmt =
+          (Function<Object, String>) fields[0].formatter();
+      Map<String, Object> out = new TreeMap<>();
+      for (Map.Entry<?, Metric> e : metrics.entrySet()) {
+        out.put(
+            fmt.apply(e.getKey()),
+            new MetricJson(e.getValue(), atts, true));
+      }
+      return out;
+    }
+
+    Map<String, Object> out = new TreeMap<>();
+    for (Map.Entry<?, Metric> e : metrics.entrySet()) {
+      ImmutableList<Object> keys = (ImmutableList<Object>) e.getKey();
+      Map<String, Object> dst = out;
+
+      for (int i = 0; i < fields.length - 1; i++) {
+        Function<Object, String> fmt =
+            (Function<Object, String>) fields[i].formatter();
+        String key = fmt.apply(keys.get(i));
+        Map<String, Object> t = (Map<String, Object>) dst.get(key);
+        if (t == null) {
+          t = new TreeMap<>();
+          dst.put(key, t);
+        }
+        dst = t;
+      }
+
+      Function<Object, String> fmt =
+          (Function<Object, String>) fields[fields.length - 1].formatter();
+      dst.put(
+          fmt.apply(keys.get(fields.length - 1)),
+          new MetricJson(e.getValue(), atts, true));
+    }
+    return out;
+  }
+
+  static class FieldJson {
+    String name;
+    String type;
+    String description;
+
+    FieldJson(Field<?> field) {
+      this.name = field.getName();
+      this.description = field.getDescription();
+      this.type = Enum.class.isAssignableFrom(field.getType())
+          ? field.getType().getSimpleName()
+          : null;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
new file mode 100644
index 0000000..d073f37
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
@@ -0,0 +1,42 @@
+// 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.metrics.dropwizard;
+
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.TypeLiteral;
+
+import com.codahale.metrics.Metric;
+
+class MetricResource extends ConfigResource {
+  static final TypeLiteral<RestView<MetricResource>> METRIC_KIND =
+      new TypeLiteral<RestView<MetricResource>>() {};
+
+  private final String name;
+  private final Metric metric;
+
+  MetricResource(String name, Metric metric) {
+    this.name = name;
+    this.metric = metric;
+  }
+
+  String getName() {
+    return name;
+  }
+
+  Metric getMetric() {
+    return metric;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
new file mode 100644
index 0000000..81945f1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
@@ -0,0 +1,72 @@
+// 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.metrics.dropwizard;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+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.server.CurrentUser;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import com.codahale.metrics.Metric;
+
+@Singleton
+class MetricsCollection implements
+    ChildCollection<ConfigResource, MetricResource> {
+  private final DynamicMap<RestView<MetricResource>> views;
+  private final Provider<ListMetrics> list;
+  private final Provider<CurrentUser> user;
+  private final DropWizardMetricMaker metrics;
+
+  @Inject
+  MetricsCollection(DynamicMap<RestView<MetricResource>> views,
+      Provider<ListMetrics> list, Provider<CurrentUser> user,
+      DropWizardMetricMaker metrics) {
+    this.views = views;
+    this.list = list;
+    this.user = user;
+    this.metrics = metrics;
+  }
+
+  @Override
+  public DynamicMap<RestView<MetricResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<ConfigResource> list() {
+    return list.get();
+  }
+
+  @Override
+  public MetricResource parse(ConfigResource parent, IdString id)
+      throws ResourceNotFoundException, AuthException {
+    if (!user.get().getCapabilities().canViewCaches()) {
+      throw new AuthException("restricted to viewCaches");
+    }
+
+    Metric metric = metrics.getMetric(id.get());
+    if (metric == null) {
+      throw new ResourceNotFoundException(id.get());
+    }
+    return new MetricResource(id.get(), metric);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
new file mode 100644
index 0000000..0164f6f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
@@ -0,0 +1,54 @@
+// 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.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Timer1;
+
+import java.util.concurrent.TimeUnit;
+
+/** Optimized version of {@link BucketedTimer} for single dimension. */
+class TimerImpl1<F1> extends BucketedTimer implements BucketedMetric {
+  TimerImpl1(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<F1> field1) {
+    super(metrics, name, desc, field1);
+  }
+
+  Timer1<F1> timer() {
+    return new Timer1<F1>() {
+      @Override
+      public void record(F1 field1, long value, TimeUnit unit) {
+        total.record(value, unit);
+        forceCreate(field1).record(value, unit);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @Override
+  String name(Object field1) {
+    @SuppressWarnings("unchecked")
+    Function<Object, String> fmt =
+        (Function<Object, String>) fields[0].formatter();
+
+    return fmt.apply(field1).replace('/', '-');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
new file mode 100644
index 0000000..49c9f14
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
@@ -0,0 +1,78 @@
+// 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.metrics.dropwizard;
+
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.metrics.Timer3;
+
+import java.util.concurrent.TimeUnit;
+
+/** Generalized implementation of N-dimensional timer metrics. */
+class TimerImplN extends BucketedTimer implements BucketedMetric {
+  TimerImplN(DropWizardMetricMaker metrics, String name,
+      Description desc, Field<?>... fields) {
+    super(metrics, name, desc, fields);
+  }
+
+  <F1, F2> Timer2<F1, F2> timer2() {
+    return new Timer2<F1, F2>() {
+      @Override
+      public void record(F1 field1, F2 field2, long value, TimeUnit unit) {
+        total.record(value, unit);
+        forceCreate(field1, field2).record(value, unit);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  <F1, F2, F3> Timer3<F1, F2, F3> timer3() {
+    return new Timer3<F1, F2, F3>() {
+      @Override
+      public void record(F1 field1, F2 field2, F3 field3,
+          long value, TimeUnit unit) {
+        total.record(value, unit);
+        forceCreate(field1, field2, field3).record(value, unit);
+      }
+
+      @Override
+      public void remove() {
+        doRemove();
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  String name(Object key) {
+    ImmutableList<Object> keyList = (ImmutableList<Object>) key;
+    String[] parts = new String[fields.length];
+    for (int i = 0; i < fields.length; i++) {
+      Function<Object, String> fmt =
+          (Function<Object, String>) fields[i].formatter();
+
+      parts[i] = fmt.apply(keyList.get(i)).replace('/', '-');
+    }
+    return Joiner.on('/').join(parts);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
new file mode 100644
index 0000000..b5a2fcc8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
@@ -0,0 +1,53 @@
+// 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.metrics.proc;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+
+import org.eclipse.jgit.internal.storage.file.WindowCacheStatAccessor;
+
+public class JGitMetricModule extends MetricModule {
+  @Override
+  protected void configure(MetricMaker metrics) {
+    metrics.newCallbackMetric(
+      "jgit/block_cache/cache_used",
+      Long.class,
+      new Description("Bytes of memory retained in JGit block cache.")
+        .setGauge()
+        .setUnit(Units.BYTES),
+      new Supplier<Long>() {
+        @Override
+        public Long get() {
+          return WindowCacheStatAccessor.getOpenBytes();
+        }
+      });
+
+    metrics.newCallbackMetric(
+        "jgit/block_cache/open_files",
+        Integer.class,
+        new Description("File handles held open by JGit block cache.")
+          .setGauge()
+          .setUnit("fds"),
+        new Supplier<Integer>() {
+          @Override
+          public Integer get() {
+            return WindowCacheStatAccessor.getOpenFiles();
+          }
+        });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java
new file mode 100644
index 0000000..c556ee4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java
@@ -0,0 +1,43 @@
+// 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.metrics.proc;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+
+/** Guice module to configure metrics on server startup. */
+public abstract class MetricModule extends LifecycleModule {
+  /** Configure metrics during server startup. */
+  protected abstract void configure(MetricMaker metrics);
+
+  @Override
+  protected void configure() {
+    listener().toInstance(new LifecycleListener() {
+      @Inject
+      MetricMaker metrics;
+
+      @Override
+      public void start() {
+        configure(metrics);
+      }
+
+      @Override
+      public void stop() {
+      }
+    });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
new file mode 100644
index 0000000..53b860c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -0,0 +1,226 @@
+// 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.metrics.proc;
+
+import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Version;
+import com.google.gerrit.metrics.CallbackMetric;
+import com.google.gerrit.metrics.CallbackMetric0;
+import com.google.gerrit.metrics.CallbackMetric1;
+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.sun.management.OperatingSystemMXBean;
+import com.sun.management.UnixOperatingSystemMXBean;
+
+import java.lang.management.GarbageCollectorMXBean;
+import java.lang.management.ManagementFactory;
+import java.lang.management.MemoryMXBean;
+import java.lang.management.MemoryUsage;
+import java.lang.management.ThreadMXBean;
+import java.util.concurrent.TimeUnit;
+
+@SuppressWarnings("restriction")
+public class ProcMetricModule extends MetricModule {
+  @Override
+  protected void configure(MetricMaker metrics) {
+    buildLabel(metrics);
+    procUptime(metrics);
+    procCpuUsage(metrics);
+    procJvmGc(metrics);
+    procJvmMemory(metrics);
+    procJvmThread(metrics);
+  }
+
+  private void buildLabel(MetricMaker metrics) {
+    metrics.newConstantMetric(
+        "build/label",
+        Strings.nullToEmpty(Version.getVersion()),
+        new Description("Version of Gerrit server software"));
+  }
+
+  private void procUptime(MetricMaker metrics) {
+    metrics.newConstantMetric(
+        "proc/birth_timestamp",
+        Long.valueOf(TimeUnit.MILLISECONDS.toMicros(
+            System.currentTimeMillis())),
+        new Description("Time at which the process started")
+          .setUnit(Units.MICROSECONDS));
+
+    metrics.newCallbackMetric(
+        "proc/uptime",
+        Long.class,
+        new Description("Uptime of this process")
+          .setUnit(Units.MILLISECONDS),
+        new Supplier<Long>() {
+          @Override
+          public Long get() {
+            return ManagementFactory.getRuntimeMXBean().getUptime();
+          }
+        });
+  }
+
+  private void procCpuUsage(MetricMaker metrics) {
+    final OperatingSystemMXBean sys =
+        (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
+    if (sys.getProcessCpuTime() != -1) {
+      metrics.newCallbackMetric(
+          "proc/cpu/usage",
+          Double.class,
+          new Description("CPU time used by the process")
+            .setCumulative()
+            .setUnit(Units.SECONDS),
+          new Supplier<Double>() {
+            @Override
+            public Double get() {
+              return sys.getProcessCpuTime() / 1e9;
+            }
+          });
+    }
+    if (sys instanceof UnixOperatingSystemMXBean) {
+      final UnixOperatingSystemMXBean unix = (UnixOperatingSystemMXBean) sys;
+      if (unix.getOpenFileDescriptorCount() != -1) {
+        metrics.newCallbackMetric(
+            "proc/num_open_fds",
+            Long.class,
+            new Description("Number of open file descriptors")
+              .setGauge()
+              .setUnit("fds"),
+            new Supplier<Long>() {
+              @Override
+              public Long get() {
+                return unix.getOpenFileDescriptorCount();
+              }
+            });
+      }
+    }
+  }
+
+  private void procJvmMemory(MetricMaker metrics) {
+    final CallbackMetric0<Long> heapCommitted = metrics.newCallbackMetric(
+        "proc/jvm/memory/heap_committed",
+        Long.class,
+        new Description("Amount of memory guaranteed for user objects.")
+          .setGauge()
+          .setUnit(Units.BYTES));
+
+    final CallbackMetric0<Long> heapUsed = metrics.newCallbackMetric(
+        "proc/jvm/memory/heap_used",
+        Long.class,
+        new Description("Amount of memory holding user objects.")
+          .setGauge()
+          .setUnit(Units.BYTES));
+
+    final CallbackMetric0<Long> nonHeapCommitted = metrics.newCallbackMetric(
+        "proc/jvm/memory/non_heap_committed",
+        Long.class,
+        new Description("Amount of memory guaranteed for classes, etc.")
+          .setGauge()
+          .setUnit(Units.BYTES));
+
+    final CallbackMetric0<Long> nonHeapUsed = metrics.newCallbackMetric(
+        "proc/jvm/memory/non_heap_used",
+        Long.class,
+        new Description("Amount of memory holding classes, etc.")
+          .setGauge()
+          .setUnit(Units.BYTES));
+
+    final CallbackMetric0<Integer> objectPendingFinalizationCount =
+        metrics.newCallbackMetric(
+        "proc/jvm/memory/object_pending_finalization_count",
+        Integer.class,
+        new Description("Approximate number of objects needing finalization.")
+          .setGauge()
+          .setUnit("objects"));
+
+    final MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
+    metrics.newTrigger(
+      ImmutableSet.<CallbackMetric<?>> of(
+          heapCommitted, heapUsed, nonHeapCommitted,
+          nonHeapUsed, objectPendingFinalizationCount),
+      new Runnable() {
+        @Override
+        public void run() {
+          try {
+            MemoryUsage stats = memory.getHeapMemoryUsage();
+            heapCommitted.set(stats.getCommitted());
+            heapUsed.set(stats.getUsed());
+          } catch (IllegalArgumentException e) {
+            // MXBean may throw due to a bug in Java 7; ignore.
+          }
+
+          MemoryUsage stats = memory.getNonHeapMemoryUsage();
+          nonHeapCommitted.set(stats.getCommitted());
+          nonHeapUsed.set(stats.getUsed());
+
+          objectPendingFinalizationCount.set(
+              memory.getObjectPendingFinalizationCount());
+        }
+      });
+  }
+
+  private void procJvmGc(MetricMaker metrics) {
+    final CallbackMetric1<String, Long> gcCount = metrics.newCallbackMetric(
+        "proc/jvm/gc/count",
+        Long.class,
+        new Description("Number of GCs").setCumulative(),
+        Field.ofString("gc_name", "The name of the garbage collector"));
+
+    final CallbackMetric1<String, Long> gcTime = metrics.newCallbackMetric(
+        "proc/jvm/gc/time",
+        Long.class,
+        new Description("Approximate accumulated GC elapsed time")
+          .setCumulative()
+          .setUnit(Units.MILLISECONDS),
+        Field.ofString("gc_name", "The name of the garbage collector"));
+
+    metrics.newTrigger(gcCount, gcTime, new Runnable() {
+      @Override
+      public void run() {
+        for (GarbageCollectorMXBean gc : ManagementFactory
+            .getGarbageCollectorMXBeans()) {
+          long count = gc.getCollectionCount();
+          if (count != -1) {
+            gcCount.set(gc.getName(), count);
+          }
+          long time = gc.getCollectionTime();
+          if (time != -1) {
+            gcTime.set(gc.getName(), time);
+          }
+        }
+      }
+    });
+  }
+
+  private void procJvmThread(MetricMaker metrics) {
+    final ThreadMXBean thread = ManagementFactory.getThreadMXBean();
+    metrics.newCallbackMetric(
+        "proc/jvm/thread/num_live",
+        Integer.class,
+        new Description("Current live thread count")
+          .setGauge()
+          .setUnit("threads"),
+        new Supplier<Integer>() {
+          @Override
+          public Integer get() {
+            return thread.getThreadCount();
+          }
+        });
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java
index 4bd9423..9d32e38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java
@@ -30,5 +30,5 @@
 @ExtensionPoint
 public interface PredicateProvider {
   /** Return set of packages that contain Prolog predicates */
-  public ImmutableSet<String> getPackages();
+  ImmutableSet<String> getPackages();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java b/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java
index 5dd26f9..f6df335a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java
@@ -75,20 +75,9 @@
  */
 @Singleton
 public class RulesCache {
-  /** Maximum size of a dynamic Prolog script, in bytes. */
-  private static final int SRC_LIMIT = 128 * 1024;
-
-  /** Default size of the internal Prolog database within each interpreter. */
-  private static final int DB_MAX = 256;
-
   private static final List<String> PACKAGE_LIST = ImmutableList.of(
       Prolog.BUILTIN, "gerrit");
 
-  private final Map<ObjectId, MachineRef> machineCache = new HashMap<>();
-
-  private final ReferenceQueue<PrologMachineCopy> dead =
-      new ReferenceQueue<>();
-
   private static final class MachineRef extends WeakReference<PrologMachineCopy> {
     final ObjectId key;
 
@@ -100,17 +89,25 @@
   }
 
   private final boolean enableProjectRules;
+  private final int maxDbSize;
+  private final int maxSrcBytes;
   private final Path cacheDir;
   private final Path rulesDir;
   private final GitRepositoryManager gitMgr;
   private final DynamicSet<PredicateProvider> predicateProviders;
   private final ClassLoader systemLoader;
   private final PrologMachineCopy defaultMachine;
+  private final Map<ObjectId, MachineRef> machineCache = new HashMap<>();
+  private final ReferenceQueue<PrologMachineCopy> dead =
+      new ReferenceQueue<>();
 
   @Inject
   protected RulesCache(@GerritServerConfig Config config, SitePaths site,
       GitRepositoryManager gm, DynamicSet<PredicateProvider> predicateProviders) {
-    enableProjectRules = config.getBoolean("rules", null, "enable", true);
+    maxDbSize = config.getInt("rules", null, "maxPrologDatabaseSize", 256);
+    maxSrcBytes = config.getInt("rules", null, "maxSourceBytes", 128 << 10);
+    enableProjectRules = config.getBoolean("rules", null, "enable", true)
+        && maxSrcBytes > 0;
     cacheDir = site.resolve(config.getString("cache", null, "directory"));
     rulesDir = cacheDir != null ? cacheDir.resolve("rules") : null;
     gitMgr = gm;
@@ -267,7 +264,7 @@
     try (Repository git = gitMgr.openRepository(project)) {
       try {
         ObjectLoader ldr = git.open(rulesId, Constants.OBJ_BLOB);
-        byte[] raw = ldr.getCachedBytes(SRC_LIMIT);
+        byte[] raw = ldr.getCachedBytes(maxSrcBytes);
         return RawParseUtils.decode(raw);
       } catch (LargeObjectException e) {
         throw new CompileException("rules of " + project + " are too large", e);
@@ -281,7 +278,7 @@
 
   private BufferingPrologControl newEmptyMachine(ClassLoader cl) {
     BufferingPrologControl ctl = new BufferingPrologControl();
-    ctl.setMaxDatabaseSize(DB_MAX);
+    ctl.setMaxDatabaseSize(maxDbSize);
     ctl.setPrologClassLoader(new PrologClassLoader(new PredicateClassLoader(
         predicateProviders, cl)));
     ctl.setEnabled(EnumSet.allOf(Prolog.Feature.class), false);
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 31058bc..7551d5f 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
@@ -15,6 +15,8 @@
 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 com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
@@ -44,7 +46,7 @@
 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.notedb.ReviewerState;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -114,10 +116,10 @@
    * @param notes change notes.
    * @return multimap of reviewers keyed by state, where each account appears
    *     exactly once in {@link SetMultimap#values()}, and
-   *     {@link ReviewerState#REMOVED} is not present.
+   *     {@link ReviewerStateInternal#REMOVED} is not present.
    * @throws OrmException if reviewers for the change could not be read.
    */
-  public ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers(
+  public ImmutableSetMultimap<ReviewerStateInternal, Account.Id> getReviewers(
       ReviewDb db, ChangeNotes notes) throws OrmException {
     if (!migration.readChanges()) {
       return getReviewers(db.patchSetApprovals().byChange(notes.getChangeId()));
@@ -132,9 +134,9 @@
    *     change.
    * @return multimap of reviewers keyed by state, where each account appears
    *     exactly once in {@link SetMultimap#values()}, and
-   *     {@link ReviewerState#REMOVED} is not present.
+   *     {@link ReviewerStateInternal#REMOVED} is not present.
    */
-  public ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers(
+  public ImmutableSetMultimap<ReviewerStateInternal, Account.Id> getReviewers(
       ChangeNotes notes, Iterable<PatchSetApproval> allApprovals)
       throws OrmException {
     if (!migration.readChanges()) {
@@ -143,10 +145,10 @@
     return notes.load().getReviewers();
   }
 
-  private static ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers(
+  private static ImmutableSetMultimap<ReviewerStateInternal, Account.Id> getReviewers(
       Iterable<PatchSetApproval> allApprovals) {
     PatchSetApproval first = null;
-    SetMultimap<ReviewerState, Account.Id> reviewers =
+    SetMultimap<ReviewerStateInternal, Account.Id> reviewers =
         LinkedHashMultimap.create();
     for (PatchSetApproval psa : allApprovals) {
       if (first == null) {
@@ -159,10 +161,10 @@
       }
       Account.Id id = psa.getAccountId();
       if (psa.getValue() != 0) {
-        reviewers.put(ReviewerState.REVIEWER, id);
-        reviewers.remove(ReviewerState.CC, id);
-      } else if (!reviewers.containsEntry(ReviewerState.REVIEWER, id)) {
-        reviewers.put(ReviewerState.CC, id);
+        reviewers.put(REVIEWER, id);
+        reviewers.remove(CC, id);
+      } else if (!reviewers.containsEntry(REVIEWER, id)) {
+        reviewers.put(CC, id);
       }
     }
     return ImmutableSetMultimap.copyOf(reviewers);
@@ -215,7 +217,7 @@
       cells.add(new PatchSetApproval(
           new PatchSetApproval.Key(psId, account, labelId),
           (short) 0, TimeUtil.nowTs()));
-      update.putReviewer(account, ReviewerState.REVIEWER);
+      update.putReviewer(account, REVIEWER);
     }
     db.patchSetApprovals().insert(cells);
     return Collections.unmodifiableList(cells);
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 f31a65b..94973f2 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
@@ -54,7 +54,7 @@
       return
           sortChangeMessages(db.changeMessages().byChange(notes.getChangeId()));
     } else {
-      return sortChangeMessages(notes.load().getChangeMessages().values());
+      return notes.load().getChangeMessages();
     }
   }
 
@@ -63,7 +63,7 @@
     if (!migration.readChanges()) {
       return db.changeMessages().byPatchSet(psId);
     }
-    return notes.load().getChangeMessages().get(psId);
+    return notes.load().getChangeMessagesByPatchSet().get(psId);
   }
 
   public void addChangeMessage(ReviewDb db, ChangeUpdate update,
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 6c80d26..8cb0c9c 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,15 +14,14 @@
 
 package com.google.gerrit.server;
 
-import static com.google.gerrit.server.query.change.ChangeData.asChanges;
-
 import com.google.common.base.Function;
 import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Ordering;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.TimeUtil;
 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.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -32,17 +31,16 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeTriplet;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.BatchUpdate;
 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.index.ChangeIndexer;
 import com.google.gerrit.server.mail.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.gwtorm.server.OrmConcurrencyException;
@@ -54,25 +52,22 @@
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.NullProgressMonitor;
 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.RefDatabase;
-import org.eclipse.jgit.lib.RefUpdate;
 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 org.eclipse.jgit.util.ChangeIdUtil;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.text.MessageFormat;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -177,38 +172,35 @@
     return subject;
   }
 
-  private final Provider<IdentifiedUser> user;
+  private final Provider<CurrentUser> user;
   private final Provider<ReviewDb> db;
   private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeControl.GenericFactory changeControlFactory;
   private final RevertedSender.Factory revertedSenderFactory;
   private final ChangeInserter.Factory changeInserterFactory;
   private final GitRepositoryManager gitManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final ChangeIndexer indexer;
   private final BatchUpdate.Factory updateFactory;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final ChangeUpdate.Factory changeUpdateFactory;
 
   @Inject
-  ChangeUtil(Provider<IdentifiedUser> user,
+  ChangeUtil(Provider<CurrentUser> user,
       Provider<ReviewDb> db,
       Provider<InternalChangeQuery> queryProvider,
+      ChangeControl.GenericFactory changeControlFactory,
       RevertedSender.Factory revertedSenderFactory,
       ChangeInserter.Factory changeInserterFactory,
       GitRepositoryManager gitManager,
-      GitReferenceUpdated gitRefUpdated,
-      ChangeIndexer indexer,
       BatchUpdate.Factory updateFactory,
       ChangeMessagesUtil changeMessagesUtil,
       ChangeUpdate.Factory changeUpdateFactory) {
     this.user = user;
     this.db = db;
     this.queryProvider = queryProvider;
+    this.changeControlFactory = changeControlFactory;
     this.revertedSenderFactory = revertedSenderFactory;
     this.changeInserterFactory = changeInserterFactory;
     this.gitManager = gitManager;
-    this.gitRefUpdated = gitRefUpdated;
-    this.indexer = indexer;
     this.updateFactory = updateFactory;
     this.changeMessagesUtil = changeMessagesUtil;
     this.changeUpdateFactory = changeUpdateFactory;
@@ -232,7 +224,7 @@
       RevCommit commitToRevert =
           revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
 
-      PersonIdent authorIdent = user.get()
+      PersonIdent authorIdent = user.get().asIdentifiedUser()
           .newCommitterIdent(myIdent.getWhen(), myIdent.getTimeZone());
 
       if (commitToRevert.getParentCount() == 0) {
@@ -338,137 +330,59 @@
     }
   }
 
-  public void deleteDraftChange(Change change)
-      throws NoSuchChangeException, OrmException, IOException {
-    Change.Id changeId = change.getId();
-    if (change.getStatus() != Change.Status.DRAFT) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    ReviewDb db = this.db.get();
-    db.changes().beginTransaction(change.getId());
-    try {
-      List<PatchSet> patchSets = db.patchSets().byChange(changeId).toList();
-      for (PatchSet ps : patchSets) {
-        if (!ps.isDraft()) {
-          throw new NoSuchChangeException(changeId);
-        }
-        db.accountPatchReviews().delete(
-            db.accountPatchReviews().byPatchSet(ps.getId()));
-      }
-
-      // No need to delete from notedb; draft patch sets will be filtered out.
-      db.patchComments().delete(db.patchComments().byChange(changeId));
-
-      db.patchSetApprovals().delete(db.patchSetApprovals().byChange(changeId));
-      db.patchSets().delete(patchSets);
-      db.changeMessages().delete(db.changeMessages().byChange(changeId));
-      db.starredChanges().delete(db.starredChanges().byChange(changeId));
-      db.changes().delete(Collections.singleton(change));
-
-      // Delete all refs at once.
-      try (Repository repo = gitManager.openRepository(change.getProject());
-          RevWalk rw = new RevWalk(repo)) {
-        String prefix = new PatchSet.Id(changeId, 1).toRefName();
-        prefix = prefix.substring(0, prefix.length() - 1);
-        BatchRefUpdate ru = repo.getRefDatabase().newBatchUpdate();
-        for (Ref ref : repo.getRefDatabase().getRefs(prefix).values()) {
-          ru.addCommand(
-              new ReceiveCommand(
-                ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
-        }
-        ru.execute(rw, NullProgressMonitor.INSTANCE);
-        for (ReceiveCommand cmd : ru.getCommands()) {
-          if (cmd.getResult() != ReceiveCommand.Result.OK) {
-            throw new IOException("failed: " + cmd + ": " + cmd.getResult());
-          }
-        }
-      }
-
-      db.commit();
-      indexer.delete(change.getId());
-    } finally {
-      db.rollback();
-    }
-  }
-
-  public void deleteOnlyDraftPatchSet(PatchSet patch, Change change)
-      throws NoSuchChangeException, OrmException, IOException {
-    PatchSet.Id patchSetId = patch.getId();
-    if (!patch.isDraft()) {
-      throw new NoSuchChangeException(patchSetId.getParentKey());
-    }
-
-    try (Repository repo = gitManager.openRepository(change.getProject())) {
-      RefUpdate update = repo.updateRef(patch.getRefName());
-      update.setForceUpdate(true);
-      update.disableRefLog();
-      switch (update.delete()) {
-        case NEW:
-        case FAST_FORWARD:
-        case FORCED:
-        case NO_CHANGE:
-          // Successful deletion.
-          break;
-        default:
-          throw new IOException("Failed to delete ref " + patch.getRefName() +
-              " in " + repo.getDirectory() + ": " + update.getResult());
-      }
-      gitRefUpdated.fire(change.getProject(), update, ReceiveCommand.Type.DELETE);
-    }
-
-    deleteOnlyDraftPatchSetPreserveRef(this.db.get(), patch);
-  }
-
   /**
    * Find changes matching the given identifier.
    *
    * @param id change identifier, either a numeric ID, a Change-Id, or
    *     project~branch~id triplet.
-   * @return all matching changes, even if they are not visible to the current
-   *     user.
+   * @param user user to wrap in controls.
+   * @return possibly-empty list of controls for all matching changes,
+   *     corresponding to the given user; may or may not be visible.
+   * @throws OrmException if an error occurred querying the database.
    */
-  public List<Change> findChanges(String id)
-      throws OrmException, ResourceNotFoundException {
+  public List<ChangeControl> findChanges(String id, CurrentUser user)
+      throws OrmException {
     // Try legacy id
-    if (id.matches("^[1-9][0-9]*$")) {
-      Change c = db.get().changes().get(Change.Id.parse(id));
-      if (c != null) {
-        return ImmutableList.of(c);
+    if (!id.isEmpty() && id.charAt(0) != '0') {
+      Integer n = Ints.tryParse(id);
+      try {
+        if (n != null) {
+          return ImmutableList.of(
+              changeControlFactory.controlFor(new Change.Id(n), user));
+        }
+      } catch (NoSuchChangeException e) {
+        return Collections.emptyList();
       }
-      return Collections.emptyList();
     }
 
+    // Use the index to search for changes, but don't return any stored fields,
+    // to force rereading in case the index is stale.
+    InternalChangeQuery query = queryProvider.get()
+        .setRequestedFields(ImmutableSet.<String> of());
+
     // Try isolated changeId
     if (!id.contains("~")) {
-      return asChanges(queryProvider.get().byKeyPrefix(id));
+      return asChangeControls(query.byKeyPrefix(id));
     }
 
     // Try change triplet
     Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id);
     if (triplet.isPresent()) {
-      return asChanges(queryProvider.get().byBranchKey(
+      return asChangeControls(query.byBranchKey(
           triplet.get().branch(),
           triplet.get().id()));
     }
 
-    throw new ResourceNotFoundException(id);
+    return Collections.emptyList();
   }
 
-  private static void deleteOnlyDraftPatchSetPreserveRef(ReviewDb db,
-      PatchSet patch) throws NoSuchChangeException, OrmException {
-    PatchSet.Id patchSetId = patch.getId();
-    if (!patch.isDraft()) {
-      throw new NoSuchChangeException(patchSetId.getParentKey());
+  private List<ChangeControl> asChangeControls(List<ChangeData> cds)
+      throws OrmException {
+    List<ChangeControl> ctls = new ArrayList<>(cds.size());
+    for (ChangeData cd : cds) {
+      ctls.add(cd.changeControl(user.get()));
     }
-
-    db.accountPatchReviews().delete(db.accountPatchReviews().byPatchSet(patchSetId));
-    db.changeMessages().delete(db.changeMessages().byPatchSet(patchSetId));
-    // No need to delete from notedb; draft patch sets will be filtered out.
-    db.patchComments().delete(db.patchComments().byPatchSet(patchSetId));
-    db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(patchSetId));
-
-    db.patchSets().delete(Collections.singleton(patch));
+    return ctls;
   }
 
   public static PatchSet.Id nextPatchSetId(PatchSet.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
index 2768733..8d31c11 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
@@ -23,7 +22,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.StarredChange;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
@@ -68,6 +66,7 @@
   @Singleton
   public static class GenericFactory {
     private final CapabilityControl.Factory capabilityControlFactory;
+    private final StarredChangesUtil starredChangesUtil;
     private final AuthConfig authConfig;
     private final Realm realm;
     private final String anonymousCowardName;
@@ -79,6 +78,7 @@
     @Inject
     public GenericFactory(
         @Nullable CapabilityControl.Factory capabilityControlFactory,
+        @Nullable StarredChangesUtil starredChangesUtil,
         AuthConfig authConfig,
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
@@ -87,6 +87,7 @@
         AccountCache accountCache,
         GroupBackend groupBackend) {
       this.capabilityControlFactory = capabilityControlFactory;
+      this.starredChangesUtil = starredChangesUtil;
       this.authConfig = authConfig;
       this.realm = realm;
       this.anonymousCowardName = anonymousCowardName;
@@ -101,23 +102,23 @@
     }
 
     public IdentifiedUser create(Provider<ReviewDb> db, Account.Id id) {
-      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
-          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, null, db, id, null);
+      return new IdentifiedUser(capabilityControlFactory, starredChangesUtil,
+          authConfig, realm, anonymousCowardName, canonicalUrl, accountCache,
+          groupBackend, disableReverseDnsLookup, null, db, id, null);
     }
 
     public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
-      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
-          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, Providers.of(remotePeer), null,
+      return new IdentifiedUser(capabilityControlFactory, starredChangesUtil,
+          authConfig, realm, anonymousCowardName, canonicalUrl, accountCache,
+          groupBackend, disableReverseDnsLookup, Providers.of(remotePeer), null,
           id, null);
     }
 
     public CurrentUser runAs(SocketAddress remotePeer, Account.Id id,
         @Nullable CurrentUser caller) {
-      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
-          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, Providers.of(remotePeer), null,
+      return new IdentifiedUser(capabilityControlFactory, starredChangesUtil,
+          authConfig, realm, anonymousCowardName, canonicalUrl, accountCache,
+          groupBackend, disableReverseDnsLookup, Providers.of(remotePeer), null,
           id, caller);
     }
   }
@@ -131,6 +132,7 @@
   @Singleton
   public static class RequestFactory {
     private final CapabilityControl.Factory capabilityControlFactory;
+    private final StarredChangesUtil starredChangesUtil;
     private final AuthConfig authConfig;
     private final Realm realm;
     private final String anonymousCowardName;
@@ -145,6 +147,7 @@
     @Inject
     RequestFactory(
         CapabilityControl.Factory capabilityControlFactory,
+        StarredChangesUtil starredChangesUtil,
         final AuthConfig authConfig,
         Realm realm,
         @AnonymousCowardName final String anonymousCowardName,
@@ -155,6 +158,7 @@
         @RemotePeer final Provider<SocketAddress> remotePeerProvider,
         final Provider<ReviewDb> dbProvider) {
       this.capabilityControlFactory = capabilityControlFactory;
+      this.starredChangesUtil = starredChangesUtil;
       this.authConfig = authConfig;
       this.realm = realm;
       this.anonymousCowardName = anonymousCowardName;
@@ -167,16 +171,16 @@
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
-          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, remotePeerProvider, dbProvider,
+      return new IdentifiedUser(capabilityControlFactory, starredChangesUtil,
+          authConfig, realm, anonymousCowardName, canonicalUrl, accountCache,
+          groupBackend, disableReverseDnsLookup, remotePeerProvider, dbProvider,
           id, null);
     }
 
     public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
-      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
-          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, remotePeerProvider, dbProvider,
+      return new IdentifiedUser(capabilityControlFactory, starredChangesUtil,
+          authConfig, realm, anonymousCowardName, canonicalUrl, accountCache,
+          groupBackend, disableReverseDnsLookup, remotePeerProvider, dbProvider,
           id, caller);
     }
   }
@@ -189,6 +193,9 @@
           SystemGroupBackend.ANONYMOUS_USERS,
           SystemGroupBackend.REGISTERED_USERS));
 
+  @Nullable
+  private final StarredChangesUtil starredChangesUtil;
+
   private final Provider<String> canonicalUrl;
   private final AccountCache accountCache;
   private final AuthConfig authConfig;
@@ -212,12 +219,13 @@
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
   private Set<Change.Id> starredChanges;
-  private ResultSet<StarredChange> starredQuery;
+  private ResultSet<Change.Id> starredQuery;
   private Collection<AccountProjectWatch> notificationFilters;
   private CurrentUser realUser;
 
   private IdentifiedUser(
       CapabilityControl.Factory capabilityControlFactory,
+      @Nullable StarredChangesUtil starredChangesUtil,
       final AuthConfig authConfig,
       Realm realm,
       final String anonymousCowardName,
@@ -230,6 +238,7 @@
       final Account.Id id,
       @Nullable CurrentUser realUser) {
     super(capabilityControlFactory);
+    this.starredChangesUtil = starredChangesUtil;
     this.canonicalUrl = canonicalUrl;
     this.accountCache = accountCache;
     this.groupBackend = groupBackend;
@@ -322,13 +331,16 @@
   @Override
   public Set<Change.Id> getStarredChanges() {
     if (starredChanges == null) {
-      checkRequestScope();
+      if (starredChangesUtil == null) {
+        throw new IllegalStateException("StarredChangesUtil is missing");
+      }
       try {
-        starredChanges = starredChangeIds(
-            starredQuery != null ? starredQuery : starredQuery());
-      } catch (OrmException | RuntimeException e) {
-        log.warn("Cannot query starred changes", e);
-        starredChanges = Collections.emptySet();
+        starredChanges =
+            FluentIterable.from(
+              starredQuery != null
+              ? starredQuery
+              : starredChangesUtil.query(accountId))
+            .toSet();
       } finally {
         starredQuery = null;
       }
@@ -344,14 +356,8 @@
   }
 
   public void asyncStarredChanges() {
-    if (starredChanges == null && dbProvider != null) {
-      try {
-        starredQuery = starredQuery();
-      } catch (OrmException e) {
-        log.warn("Cannot query starred by user changes", e);
-        starredQuery = null;
-        starredChanges = Collections.emptySet();
-      }
+    if (starredChanges == null && starredChangesUtil != null) {
+      starredQuery = starredChangesUtil.query(accountId);
     }
   }
 
@@ -371,21 +377,6 @@
     }
   }
 
-  private ResultSet<StarredChange> starredQuery() throws OrmException {
-    return dbProvider.get().starredChanges().byAccount(getAccountId());
-  }
-
-  private static ImmutableSet<Change.Id> starredChangeIds(
-      Iterable<StarredChange> scs) {
-    return FluentIterable.from(scs)
-        .transform(new Function<StarredChange, Change.Id>() {
-          @Override
-          public Change.Id apply(StarredChange in) {
-            return in.getChangeId();
-          }
-        }).toSet();
-  }
-
   @Override
   public Collection<AccountProjectWatch> getNotificationFilters() {
     if (notificationFilters == null) {
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
index 7b182b1..c4086e0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
@@ -275,14 +275,10 @@
       return sort(db.patchComments().draftByAuthor(author).toList());
     }
 
-    // TODO(dborowitz): Just scan author space.
-    Set<String> refNames = getRefNamesAllUsers(RefNames.REFS_DRAFT_COMMENTS);
+    Set<String> refNames =
+        getRefNamesAllUsers(RefNames.refsDraftCommentsPrefix(author));
     List<PatchLineComment> comments = Lists.newArrayList();
     for (String refName : refNames) {
-      Account.Id id = Account.Id.fromRefPart(refName);
-      if (!author.equals(id)) {
-        continue;
-      }
       Change.Id changeId = Change.Id.parse(refName);
       comments.addAll(
           draftFactory.create(changeId, author).load().getComments().values());
@@ -364,7 +360,7 @@
   }
 
   private Set<String> getRefNamesAllUsers(String prefix) throws OrmException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
+    try (Repository repo = repoManager.openMetadataRepository(allUsers)) {
       RefDatabase refDb = repo.getRefDatabase();
       return refDb.getRefs(prefix).keySet();
     } catch (IOException e) {
@@ -375,7 +371,7 @@
   private Iterable<String> getDraftRefs(final Change.Id changeId)
       throws OrmException {
     Set<String> refNames = getRefNamesAllUsers(RefNames.REFS_DRAFT_COMMENTS);
-    final String suffix = "-" + changeId.get();
+    final String suffix = "/" + changeId.get();
     return Iterables.filter(refNames, new Predicate<String>() {
       @Override
       public boolean apply(String input) {
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
new file mode 100644
index 0000000..d410ee9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -0,0 +1,277 @@
+// 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;
+
+import com.google.common.base.Function;
+import com.google.common.base.Predicate;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.Iterators;
+import com.google.common.collect.Lists;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.StarredChange;
+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.NotesMigration;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+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.RefDatabase;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+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.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+@Singleton
+public class StarredChangesUtil {
+  private static final Logger log =
+      LoggerFactory.getLogger(StarredChangesUtil.class);
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+  private final NotesMigration migration;
+  private final Provider<ReviewDb> dbProvider;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  StarredChangesUtil(GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      NotesMigration migration,
+      Provider<ReviewDb> dbProvider,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.migration = migration;
+    this.dbProvider = dbProvider;
+    this.serverIdent = serverIdent;
+  }
+
+  public void star(Account.Id accountId, Change.Id changeId)
+      throws OrmException {
+    dbProvider.get().starredChanges()
+        .insert(Collections.singleton(new StarredChange(
+            new StarredChange.Key(accountId, changeId))));
+    if (!migration.writeChanges()) {
+      return;
+    }
+    try (Repository repo = repoManager.openMetadataRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      RefUpdate u = repo.updateRef(
+          RefNames.refsStarredChanges(accountId, changeId));
+      u.setExpectedOldObjectId(ObjectId.zeroId());
+      u.setNewObjectId(emptyTree(repo));
+      u.setRefLogIdent(serverIdent);
+      u.setRefLogMessage("Star change " + changeId.get(), false);
+      RefUpdate.Result result = u.update(rw);
+      switch (result) {
+        case NEW:
+          return;
+        default:
+          throw new OrmException(
+              String.format("Star change %d for account %d failed: %s",
+                  changeId.get(), accountId.get(), result.name()));
+      }
+    } catch (IOException e) {
+      throw new OrmException(
+          String.format("Star change %d for account %d failed",
+              changeId.get(), accountId.get()), e);
+    }
+  }
+
+  private static ObjectId emptyTree(Repository repo) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId id = oi.insert(Constants.OBJ_TREE, new byte[] {});
+      oi.flush();
+      return id;
+    }
+  }
+
+  public void unstar(Account.Id accountId, Change.Id changeId)
+      throws OrmException {
+    dbProvider.get().starredChanges()
+        .delete(Collections.singleton(new StarredChange(
+            new StarredChange.Key(accountId, changeId))));
+    if (!migration.writeChanges()) {
+      return;
+    }
+    try (Repository repo = repoManager.openMetadataRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      RefUpdate u = repo.updateRef(
+          RefNames.refsStarredChanges(accountId, changeId));
+      u.setForceUpdate(true);
+      u.setRefLogIdent(serverIdent);
+      u.setRefLogMessage("Unstar change " + changeId.get(), true);
+      RefUpdate.Result result = u.delete();
+      switch (result) {
+        case FORCED:
+          return;
+        default:
+          throw new OrmException(
+              String.format("Unstar change %d for account %d failed: %s",
+                  changeId.get(), accountId.get(), result.name()));
+      }
+    } catch (IOException e) {
+      throw new OrmException(
+          String.format("Unstar change %d for account %d failed",
+              changeId.get(), accountId.get()), e);
+    }
+  }
+
+  public void unstarAll(Change.Id changeId) throws OrmException {
+    dbProvider.get().starredChanges().delete(
+        dbProvider.get().starredChanges().byChange(changeId));
+    if (!migration.writeChanges()) {
+      return;
+    }
+    try (Repository repo = repoManager.openMetadataRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
+      batchUpdate.setAllowNonFastForwards(true);
+      batchUpdate.setRefLogIdent(serverIdent);
+      batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
+      for (Account.Id accountId : byChange(changeId)) {
+        String refName = RefNames.refsStarredChanges(accountId, changeId);
+        Ref ref = repo.getRefDatabase().getRef(refName);
+        batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(),
+            ObjectId.zeroId(), refName));
+      }
+      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+      for (ReceiveCommand command : batchUpdate.getCommands()) {
+        if (command.getResult() != ReceiveCommand.Result.OK) {
+          throw new IOException(String.format(
+              "Unstar change %d failed, ref %s could not be deleted: %s",
+              changeId.get(), command.getRefName(), command.getResult()));
+        }
+      }
+    } catch (IOException e) {
+      throw new OrmException(
+          String.format("Unstar change %d failed", changeId.get()), e);
+    }
+  }
+
+  public Iterable<Account.Id> byChange(final Change.Id changeId)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return FluentIterable
+          .from(dbProvider.get().starredChanges().byChange(changeId))
+          .transform(new Function<StarredChange, Account.Id>() {
+            @Override
+            public Account.Id apply(StarredChange in) {
+              return in.getAccountId();
+            }
+          });
+    }
+    return FluentIterable.from(getRefNames(RefNames.REFS_STARRED_CHANGES))
+        .filter(new Predicate<String>() {
+          @Override
+          public boolean apply(String refPart) {
+            return refPart.endsWith("/" + changeId.get());
+          }
+        })
+        .transform(new Function<String, Account.Id>() {
+          @Override
+          public Account.Id apply(String refPart) {
+            return Account.Id.fromRefPart(refPart);
+          }
+        });
+  }
+
+  public ResultSet<Change.Id> query(Account.Id accountId) {
+    try {
+      if (!migration.readChanges()) {
+        return new ChangeIdResultSet(
+            dbProvider.get().starredChanges().byAccount(accountId));
+      }
+
+      return new ListResultSet<>(FluentIterable
+          .from(getRefNames(RefNames.refsStarredChangesPrefix(accountId)))
+          .transform(new Function<String, Change.Id>() {
+            @Override
+            public Change.Id apply(String changeId) {
+              return Change.Id.parse(changeId);
+            }
+          }).toList());
+    } catch (OrmException | RuntimeException e) {
+      log.warn(String.format("Cannot query starred changes for account %d",
+          accountId.get()), e);
+      List<Change.Id> empty = Collections.emptyList();
+      return new ListResultSet<>(empty);
+    }
+  }
+
+  private Set<String> getRefNames(String prefix) throws OrmException {
+    try (Repository repo = repoManager.openMetadataRepository(allUsers)) {
+      RefDatabase refDb = repo.getRefDatabase();
+      return refDb.getRefs(prefix).keySet();
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private static class ChangeIdResultSet implements ResultSet<Change.Id> {
+    private static final Function<StarredChange, Change.Id>
+        STARRED_CHANGE_TO_CHANGE_ID =
+            new Function<StarredChange, Change.Id>() {
+              @Override
+              public Change.Id apply(StarredChange starredChange) {
+                return starredChange.getChangeId();
+              }
+            };
+
+    private final ResultSet<StarredChange> starredChangesResultSet;
+
+    ChangeIdResultSet(ResultSet<StarredChange> starredChangesResultSet) {
+      this.starredChangesResultSet = starredChangesResultSet;
+    }
+
+    @Override
+    public Iterator<Change.Id> iterator() {
+      return Iterators.transform(starredChangesResultSet.iterator(),
+          STARRED_CHANGE_TO_CHANGE_ID);
+    }
+
+    @Override
+    public List<Change.Id> toList() {
+      return Lists.transform(starredChangesResultSet.toList(),
+          STARRED_CHANGE_TO_CHANGE_ID);
+    }
+
+    @Override
+    public void close() {
+      starredChangesResultSet.close();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java
index 406ab52..9001ea5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java
@@ -20,7 +20,7 @@
 
 /** Translates an email address to a set of matching accounts. */
 public interface AccountByEmailCache {
-  public Set<Account.Id> get(String email);
+  Set<Account.Id> get(String email);
 
-  public void evict(String email);
+  void evict(String email);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
index 86308a9..d7d418d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
@@ -18,13 +18,13 @@
 
 /** Caches important (but small) account state to avoid database hits. */
 public interface AccountCache {
-  public AccountState get(Account.Id accountId);
+  AccountState get(Account.Id accountId);
 
-  public AccountState getIfPresent(Account.Id accountId);
+  AccountState getIfPresent(Account.Id accountId);
 
-  public AccountState getByUsername(String username);
+  AccountState getByUsername(String username);
 
-  public void evict(Account.Id accountId);
+  void evict(Account.Id accountId);
 
-  public void evictByUsername(String username);
+  void evictByUsername(String username);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
index 35ab3af..35d7c70 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_EXTERNAL;
 
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 
@@ -38,6 +39,15 @@
     return r;
   }
 
+  /** Create a request for an external username. */
+  public static AuthRequest forExternalUser(String username) {
+    AccountExternalId.Key i =
+        new AccountExternalId.Key(SCHEME_EXTERNAL, username);
+    AuthRequest r = new AuthRequest(i.get());
+    r.setUserName(username);
+    return r;
+  }
+
   /**
    * Create a request for an email address registration.
    * <p>
@@ -58,6 +68,8 @@
   private String emailAddress;
   private String userName;
   private boolean skipAuthentication;
+  private String authPlugin;
+  private String authProvider;
 
   public AuthRequest(final String externalId) {
     this.externalId = externalId;
@@ -125,4 +137,20 @@
   public void setSkipAuthentication(boolean skip) {
     skipAuthentication = skip;
   }
+
+  public String getAuthPlugin() {
+    return authPlugin;
+  }
+
+  public void setAuthPlugin(String authPlugin) {
+    this.authPlugin = authPlugin;
+  }
+
+  public String getAuthProvider() {
+    return authProvider;
+  }
+
+  public void setAuthProvider(String authProvider) {
+    this.authProvider = authProvider;
+  }
 }
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 7bb00c5..9d580f5 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
@@ -40,7 +40,7 @@
 /** Access control management for server-wide capabilities. */
 public class CapabilityControl {
   public static interface Factory {
-    public CapabilityControl create(CurrentUser user);
+    CapabilityControl create(CurrentUser user);
   }
 
   private final CapabilityCollection capabilities;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
index f60492e..166f97e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
@@ -19,9 +19,9 @@
  * Expands user name to a local email address, usually by adding a domain.
  */
 public interface EmailExpander {
-  public boolean canExpand(String user);
+  boolean canExpand(String user);
 
-  public String expand(String user);
+  String expand(String user);
 
   public static class None implements EmailExpander {
     public static final None INSTANCE = new None();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
index 08bf83e..1ab8928 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DateFormat;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DiffView;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DownloadCommand;
+import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.EmailStrategy;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.ReviewCategoryStrategy;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.TimeFormat;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -104,7 +105,6 @@
     Boolean useFlashClipboard;
     String downloadScheme;
     DownloadCommand downloadCommand;
-    Boolean copySelfOnEmail;
     DateFormat dateFormat;
     TimeFormat timeFormat;
     Boolean relativeDateInChangeTable;
@@ -113,6 +113,7 @@
     Boolean muteCommonPathPrefixes;
     ReviewCategoryStrategy reviewCategoryStrategy;
     DiffView diffView;
+    EmailStrategy emailStrategy;
     List<TopMenu.MenuItem> my;
     Map<String, String> urlAliases;
 
@@ -124,7 +125,6 @@
         useFlashClipboard = p.isUseFlashClipboard() ? true : null;
         downloadScheme = p.getDownloadUrl();
         downloadCommand = p.getDownloadCommand();
-        copySelfOnEmail = p.isCopySelfOnEmails() ? true : null;
         dateFormat = p.getDateFormat();
         timeFormat = p.getTimeFormat();
         relativeDateInChangeTable = p.isRelativeDateInChangeTable() ? true : null;
@@ -133,6 +133,7 @@
         muteCommonPathPrefixes = p.isMuteCommonPathPrefixes() ? true : null;
         reviewCategoryStrategy = p.getReviewCategoryStrategy();
         diffView = p.getDiffView();
+        emailStrategy = p.getEmailStrategy();
       }
       loadFromAllUsers(v, allUsers);
     }
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 c1a4e0f..c7a2241 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
@@ -19,9 +19,9 @@
 
 /** Tracks group objects in memory for efficient access. */
 public interface GroupCache {
-  public AccountGroup get(AccountGroup.Id groupId);
+  AccountGroup get(AccountGroup.Id groupId);
 
-  public AccountGroup get(AccountGroup.NameKey name);
+  AccountGroup get(AccountGroup.NameKey name);
 
   /**
    * Lookup a group definition by its UUID. The returned definition may be null
@@ -29,16 +29,16 @@
    * copied from another server.
    */
   @Nullable
-  public AccountGroup get(AccountGroup.UUID uuid);
+  AccountGroup get(AccountGroup.UUID uuid);
 
   /** @return sorted iteration of groups. */
-  public abstract Iterable<AccountGroup> all();
+  Iterable<AccountGroup> all();
 
   /** Notify the cache that a new group was constructed. */
-  public void onCreateGroup(AccountGroup.NameKey newGroupName);
+  void onCreateGroup(AccountGroup.NameKey newGroupName);
 
-  public void evict(AccountGroup group);
+  void evict(AccountGroup group);
 
-  public void evictAfterRename(final AccountGroup.NameKey oldName,
+  void evictAfterRename(final AccountGroup.NameKey oldName,
       final AccountGroup.NameKey newName);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
index 6ba6bf0..9971301 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
@@ -21,14 +21,14 @@
 /** Tracks group inclusions in memory for efficient access. */
 public interface GroupIncludeCache {
   /** @return groups directly a member of the passed group. */
-  public Set<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID group);
+  Set<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID group);
 
   /** @return any groups the passed group belongs to. */
-  public Set<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
+  Set<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
 
   /** @return set of any UUIDs that are not internal groups. */
-  public Set<AccountGroup.UUID> allExternalMembers();
+  Set<AccountGroup.UUID> allExternalMembers();
 
-  public void evictSubgroupsOf(AccountGroup.UUID groupId);
-  public void evictParentGroupsOf(AccountGroup.UUID groupId);
+  void evictSubgroupsOf(AccountGroup.UUID groupId);
+  void evictParentGroupsOf(AccountGroup.UUID groupId);
 }
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 056fa85b..6fd76d4 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
@@ -22,26 +22,26 @@
 
 public interface Realm {
   /** Can the end-user modify this field of their own account? */
-  public boolean allowsEdit(Account.FieldName field);
+  boolean allowsEdit(Account.FieldName field);
 
   /** Returns the account fields that the end-user can modify. */
-  public Set<Account.FieldName> getEditableFields();
+  Set<Account.FieldName> getEditableFields();
 
-  public AuthRequest authenticate(AuthRequest who) throws AccountException;
+  AuthRequest authenticate(AuthRequest who) throws AccountException;
 
-  public AuthRequest link(ReviewDb db, Account.Id to, AuthRequest who)
+  AuthRequest link(ReviewDb db, Account.Id to, AuthRequest who)
       throws AccountException;
 
-  public AuthRequest unlink(ReviewDb db, Account.Id to, AuthRequest who)
+  AuthRequest unlink(ReviewDb db, Account.Id to, AuthRequest who)
       throws AccountException;
 
-  public void onCreateAccount(AuthRequest who, Account account);
+  void onCreateAccount(AuthRequest who, Account account);
 
   /** @return true if the user has the given email address. */
-  public boolean hasEmailAddress(IdentifiedUser who, String email);
+  boolean hasEmailAddress(IdentifiedUser who, String email);
 
   /** @return all known email addresses for the identified user. */
-  public Set<String> getEmailAddresses(IdentifiedUser who);
+  Set<String> getEmailAddresses(IdentifiedUser who);
 
   /**
    * Locate an account whose local username is the given account name.
@@ -51,5 +51,5 @@
    * how to convert the accountName into an email address, and then locate the
    * user by that email address.
    */
-  public Account.Id lookup(String accountName);
+  Account.Id lookup(String accountName);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
index 569d12801..7042076 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DateFormat;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DiffView;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DownloadCommand;
+import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.EmailStrategy;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.ReviewCategoryStrategy;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.TimeFormat;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -64,7 +65,6 @@
     public Boolean useFlashClipboard;
     public String downloadScheme;
     public DownloadCommand downloadCommand;
-    public Boolean copySelfOnEmail;
     public DateFormat dateFormat;
     public TimeFormat timeFormat;
     public Boolean relativeDateInChangeTable;
@@ -73,6 +73,7 @@
     public Boolean muteCommonPathPrefixes;
     public ReviewCategoryStrategy reviewCategoryStrategy;
     public DiffView diffView;
+    public EmailStrategy emailStrategy;
     public List<TopMenu.MenuItem> my;
     public Map<String, String> urlAliases;
   }
@@ -146,9 +147,6 @@
       if (i.downloadCommand != null) {
         p.setDownloadCommand(i.downloadCommand);
       }
-      if (i.copySelfOnEmail != null) {
-        p.setCopySelfOnEmails(i.copySelfOnEmail);
-      }
       if (i.dateFormat != null) {
         p.setDateFormat(i.dateFormat);
       }
@@ -173,6 +171,9 @@
       if (i.diffView != null) {
         p.setDiffView(i.diffView);
       }
+      if (i.emailStrategy != null) {
+        p.setEmailStrategy(i.emailStrategy);
+      }
 
       db.get().accounts().update(Collections.singleton(a));
       db.get().commit();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
index a3c0d37..e69bc0f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
@@ -27,10 +27,9 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.StarredChange;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.query.change.QueryChanges;
@@ -43,8 +42,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Collections;
-
 @Singleton
 public class StarredChanges implements
     ChildCollection<AccountResource, AccountResource.StarredChange>,
@@ -72,7 +69,7 @@
       user.asyncStarredChanges();
 
       ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
-      if (user.getStarredChanges().contains(change.getChange().getId())) {
+      if (user.getStarredChanges().contains(change.getId())) {
         return new AccountResource.StarredChange(user, change);
       }
       throw new ResourceNotFoundException(id);
@@ -117,13 +114,13 @@
   @Singleton
   public static class Create implements RestModifyView<AccountResource, EmptyInput> {
     private final Provider<CurrentUser> self;
-    private final Provider<ReviewDb> dbProvider;
+    private final StarredChangesUtil starredChangesUtil;
     private ChangeResource change;
 
     @Inject
-    Create(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider) {
+    Create(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
       this.self = self;
-      this.dbProvider = dbProvider;
+      this.starredChangesUtil = starredChangesUtil;
     }
 
     public Create setChange(ChangeResource change) {
@@ -138,10 +135,7 @@
         throw new AuthException("not allowed to add starred change");
       }
       try {
-        dbProvider.get().starredChanges().insert(Collections.singleton(
-            new StarredChange(new StarredChange.Key(
-                rsrc.getUser().getAccountId(),
-                change.getChange().getId()))));
+        starredChangesUtil.star(self.get().getAccountId(), change.getId());
       } catch (OrmDuplicateKeyException e) {
         return Response.none();
       }
@@ -173,12 +167,12 @@
   public static class Delete implements
       RestModifyView<AccountResource.StarredChange, EmptyInput> {
     private final Provider<CurrentUser> self;
-    private final Provider<ReviewDb> dbProvider;
+    private final StarredChangesUtil starredChangesUtil;
 
     @Inject
-    Delete(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider) {
+    Delete(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
       this.self = self;
-      this.dbProvider = dbProvider;
+      this.starredChangesUtil = starredChangesUtil;
     }
 
     @Override
@@ -187,10 +181,8 @@
       if (self.get() != rsrc.getUser()) {
         throw new AuthException("not allowed remove starred change");
       }
-      dbProvider.get().starredChanges().delete(Collections.singleton(
-          new StarredChange(new StarredChange.Key(
-              rsrc.getUser().getAccountId(),
-              rsrc.getChange().getId()))));
+      starredChangesUtil.unstar(self.get().getAccountId(),
+          rsrc.getChange().getId());
       return Response.none();
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SuggestAccounts.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SuggestAccounts.java
index 837e1ed..1cc3c94 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SuggestAccounts.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SuggestAccounts.java
@@ -15,8 +15,6 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.Ordering;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -24,6 +22,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -33,7 +32,6 @@
 
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -140,15 +138,7 @@
     }
 
     List<AccountInfo> m = new ArrayList<>(matches.values());
-    Collections.sort(m, new Comparator<AccountInfo>() {
-      @Override
-      public int compare(AccountInfo a, AccountInfo b) {
-        return ComparisonChain.start()
-          .compare(a.name, b.name, Ordering.natural().nullsLast())
-          .compare(a.email, b.email, Ordering.natural().nullsLast())
-          .result();
-      }
-    });
+    Collections.sort(m, AccountInfoComparator.ORDER_NULLS_LAST);
     return m;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java
new file mode 100644
index 0000000..3794701
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java
@@ -0,0 +1,56 @@
+// 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.api.accounts;
+
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+import java.util.Comparator;
+
+public class AccountInfoComparator extends Ordering<AccountInfo>
+    implements Comparator<AccountInfo> {
+  public static final AccountInfoComparator ORDER_NULLS_FIRST =
+      new AccountInfoComparator();
+  public static final AccountInfoComparator ORDER_NULLS_LAST =
+      new AccountInfoComparator().setNullsLast();
+
+  private boolean nullsLast;
+
+  private AccountInfoComparator() {
+  }
+
+  private AccountInfoComparator setNullsLast() {
+    this.nullsLast = true;
+    return this;
+  }
+
+  @Override
+  public int compare(AccountInfo a, AccountInfo b) {
+    return ComparisonChain.start()
+        .compare(a.name, b.name, createOrdering())
+        .compare(a.email, b.email, createOrdering())
+        .compare(a._accountId, b._accountId, createOrdering())
+        .result();
+  }
+
+  private <S extends Comparable<?>> Ordering<S> createOrdering() {
+    if (nullsLast) {
+      return Ordering.natural().nullsLast();
+    } else {
+      return Ordering.natural().nullsFirst();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
index 7be8299..3bd7634 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
@@ -61,6 +61,11 @@
   }
 
   @Override
+  public AccountApi id(int id) throws RestApiException {
+    return id(String.valueOf(id));
+  }
+
+  @Override
   public AccountApi self() throws RestApiException {
     if (!self.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
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 b55642c..d7f8cde 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
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.api.changes.RevertInput;
+import com.google.gerrit.extensions.api.changes.ReviewerApi;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -46,6 +47,7 @@
 import com.google.gerrit.server.change.PutTopic;
 import com.google.gerrit.server.change.Restore;
 import com.google.gerrit.server.change.Revert;
+import com.google.gerrit.server.change.Reviewers;
 import com.google.gerrit.server.change.Revisions;
 import com.google.gerrit.server.change.SubmittedTogether;
 import com.google.gerrit.server.change.SuggestReviewers;
@@ -68,7 +70,9 @@
 
   private final Provider<CurrentUser> user;
   private final Changes changeApi;
+  private final Reviewers reviewers;
   private final Revisions revisions;
+  private final ReviewerApiImpl.Factory reviewerApi;
   private final RevisionApiImpl.Factory revisionApi;
   private final Provider<SuggestReviewers> suggestReviewers;
   private final ChangeResource change;
@@ -90,7 +94,9 @@
   @Inject
   ChangeApiImpl(Provider<CurrentUser> user,
       Changes changeApi,
+      Reviewers reviewers,
       Revisions revisions,
+      ReviewerApiImpl.Factory reviewerApi,
       RevisionApiImpl.Factory revisionApi,
       Provider<SuggestReviewers> suggestReviewers,
       Abandon abandon,
@@ -111,7 +117,9 @@
     this.user = user;
     this.changeApi = changeApi;
     this.revert = revert;
+    this.reviewers = reviewers;
     this.revisions = revisions;
+    this.reviewerApi = reviewerApi;
     this.revisionApi = revisionApi;
     this.suggestReviewers = suggestReviewers;
     this.abandon = abandon;
@@ -132,7 +140,7 @@
 
   @Override
   public String id() {
-    return Integer.toString(change.getChange().getId().get());
+    return Integer.toString(change.getId().get());
   }
 
   @Override
@@ -156,6 +164,16 @@
   }
 
   @Override
+  public ReviewerApi reviewer(String id) throws RestApiException {
+    try {
+      return reviewerApi.create(
+          reviewers.parse(change, IdString.fromDecoded(id)));
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot parse reviewer", e);
+    }
+  }
+
+  @Override
   public void abandon() throws RestApiException {
     abandon(new AbandonInput());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index acda1ee..bb01dea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -28,6 +28,7 @@
 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.Change;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.CreateChange;
@@ -93,8 +94,7 @@
     try {
       ChangeInfo out = createChange.apply(
           TopLevelResource.INSTANCE, in).value();
-      return api.create(changes.parse(TopLevelResource.INSTANCE,
-          IdString.fromUrl(out.changeId)));
+      return api.create(changes.parse(new Change.Id(out._number)));
     } catch (OrmException | IOException | InvalidChangeOperationException
         | UpdateException e) {
       throw new RestApiException("Cannot create change", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
index 647f577..2e2dfcc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
@@ -22,12 +22,11 @@
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.change.GetDraftComment;
 import com.google.gerrit.server.change.PutDraftComment;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
-import java.io.IOException;
-
 class DraftApiImpl implements DraftApi {
   interface Factory {
     DraftApiImpl create(DraftCommentResource d);
@@ -62,7 +61,7 @@
   public CommentInfo update(DraftInput in) throws RestApiException {
     try {
       return putDraft.apply(draft, in).value();
-    } catch (IOException | OrmException e) {
+    } catch (UpdateException | OrmException e) {
       throw new RestApiException("Cannot update draft", e);
     }
   }
@@ -71,7 +70,7 @@
   public void delete() throws RestApiException {
     try {
       deleteDraft.apply(draft, null);
-    } catch (IOException | OrmException e) {
+    } catch (UpdateException e) {
       throw new RestApiException("Cannot delete draft", e);
     }
   }
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 a5e584e..228dad6 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
@@ -27,5 +27,6 @@
     factory(DraftApiImpl.Factory.class);
     factory(RevisionApiImpl.Factory.class);
     factory(FileApiImpl.Factory.class);
+    factory(ReviewerApiImpl.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
new file mode 100644
index 0000000..49b3432
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
@@ -0,0 +1,65 @@
+// 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.api.changes;
+
+import com.google.gerrit.extensions.api.changes.ReviewerApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.DeleteVote;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.VoteResource;
+import com.google.gerrit.server.change.Votes;
+import com.google.gerrit.server.git.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import java.util.Map;
+
+public class ReviewerApiImpl implements ReviewerApi {
+  interface Factory {
+    ReviewerApiImpl create(ReviewerResource r);
+  }
+
+  private final ReviewerResource reviewer;
+  private final Votes.List listVotes;
+  private final DeleteVote deleteVote;
+
+  @Inject
+  ReviewerApiImpl(Votes.List listVotes,
+      DeleteVote deleteVote,
+      @Assisted ReviewerResource reviewer) {
+    this.listVotes = listVotes;
+    this.deleteVote = deleteVote;
+    this.reviewer = reviewer;
+  }
+
+  @Override
+  public Map<String, Short> votes() throws RestApiException {
+    try {
+      return listVotes.apply(reviewer);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot list votes", e);
+    }
+  }
+
+  @Override
+  public void deleteVote(String label) throws RestApiException {
+    try {
+      deleteVote.apply(new VoteResource(reviewer, label), null);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot delete vote", 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 0926142..0ca43f5 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
@@ -183,7 +183,7 @@
   public void delete() throws RestApiException {
     try {
       deleteDraft.apply(revision, null);
-    } catch (OrmException | IOException e) {
+    } catch (UpdateException e) {
       throw new RestApiException("Cannot delete draft ps", e);
     }
   }
@@ -347,7 +347,7 @@
       return changes.id(revision.getChange().getId().get())
           .revision(revision.getPatchSet().getId().get())
           .draft(id);
-    } catch (IOException | OrmException e) {
+    } catch (UpdateException | OrmException e) {
       throw new RestApiException("Cannot create draft", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 0bb395f..ec3d9ff 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -17,12 +17,16 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+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.server.project.BranchResource;
 import com.google.gerrit.server.project.BranchesCollection;
 import com.google.gerrit.server.project.CreateBranch;
 import com.google.gerrit.server.project.DeleteBranch;
+import com.google.gerrit.server.project.FileResource;
+import com.google.gerrit.server.project.FilesCollection;
+import com.google.gerrit.server.project.GetContent;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -38,6 +42,8 @@
   private final BranchesCollection branches;
   private final CreateBranch.Factory createBranchFactory;
   private final DeleteBranch deleteBranch;
+  private final FilesCollection filesCollection;
+  private final GetContent getContent;
   private final String ref;
   private final ProjectResource project;
 
@@ -45,11 +51,15 @@
   BranchApiImpl(BranchesCollection branches,
       CreateBranch.Factory createBranchFactory,
       DeleteBranch deleteBranch,
+      FilesCollection filesCollection,
+      GetContent getContent,
       @Assisted ProjectResource project,
       @Assisted String ref) {
     this.branches = branches;
     this.createBranchFactory = createBranchFactory;
     this.deleteBranch = deleteBranch;
+    this.filesCollection = filesCollection;
+    this.getContent = getContent;
     this.project = project;
     this.ref = ref;
   }
@@ -85,6 +95,17 @@
     }
   }
 
+  @Override
+  public BinaryResult file(String path) throws RestApiException {
+    try {
+      FileResource resource = filesCollection.parse(resource(),
+        IdString.fromDecoded(path));
+      return getContent.apply(resource);
+    } catch (IOException e) {
+      throw new RestApiException("Cannot retrieve file", e);
+    }
+  }
+
   private BranchResource resource() throws RestApiException, IOException {
     return branches.parse(project, IdString.fromDecoded(ref));
   }
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
new file mode 100644
index 0000000..582cc38
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
@@ -0,0 +1,121 @@
+// 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.auth.oauth;
+
+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.registration.DynamicMap;
+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.account.AbstractRealm;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import java.io.IOException;
+
+@Singleton
+public class OAuthRealm extends AbstractRealm {
+  private final DynamicMap<OAuthLoginProvider> loginProviders;
+
+  @Inject
+  OAuthRealm(DynamicMap<OAuthLoginProvider> loginProviders) {
+    this.loginProviders = loginProviders;
+  }
+
+  @Override
+  public boolean allowsEdit(FieldName field) {
+    return false;
+  }
+
+  /**
+   * Authenticates with the {@link OAuthLoginProvider} specified
+   * in the authentication request.
+   *
+   * {@link AccountManager} calls this method without password
+   * if authenticity of the user has already been established.
+   * In that case the {@link AuthRequest} is supposed to contain
+   * a resolved email address and we can skip the authentication
+   * request to the {@code OAuthLoginService}.
+   *
+   * @param who the authentication request.
+   *
+   * @return the authentication request with resolved email address
+   * and display name in case the authenticity of the user could
+   * be established; otherwise {@code who} is returned unchanged.
+   *
+   * @throws AccountException if the authentication request with
+   * the OAuth2 server failed or no {@code OAuthLoginProvider} was
+   * available to handle the request.
+   */
+  @Override
+  public AuthRequest authenticate(AuthRequest who) throws AccountException {
+    if (Strings.isNullOrEmpty(who.getPassword()) &&
+        !Strings.isNullOrEmpty(who.getEmailAddress())) {
+      return who;
+    }
+
+    if (Strings.isNullOrEmpty(who.getAuthPlugin())
+        || Strings.isNullOrEmpty(who.getAuthProvider())) {
+      throw new AccountException("Cannot authenticate");
+    }
+    OAuthLoginProvider loginProvider =
+        loginProviders.get(who.getAuthPlugin(), who.getAuthProvider());
+    if (loginProvider == null) {
+      throw new AccountException("Cannot authenticate");
+    }
+
+    OAuthUserInfo userInfo;
+    try {
+      userInfo = loginProvider.login(who.getUserName(), who.getPassword());
+    } catch (IOException e) {
+      throw new AccountException("Cannot authenticate", e);
+    }
+    if (userInfo == null) {
+      throw new AccountException("Cannot authenticate");
+    }
+    if (!Strings.isNullOrEmpty(userInfo.getEmailAddress())) {
+      who.setEmailAddress(userInfo.getEmailAddress());
+    }
+    if (!Strings.isNullOrEmpty(userInfo.getDisplayName())) {
+      who.setDisplayName(userInfo.getDisplayName());
+    }
+    return who;
+  }
+
+  @Override
+  public AuthRequest link(ReviewDb db, Account.Id to, AuthRequest who) {
+    return who;
+  }
+
+  @Override
+  public AuthRequest unlink(ReviewDb db, Account.Id to, AuthRequest who)
+      throws AccountException {
+    return who;
+  }
+
+  @Override
+  public void onCreateAccount(AuthRequest who, Account account) {
+  }
+
+  @Override
+  public Account.Id lookup(String accountName) {
+    return null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
index 4b30a8d..571a7e5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
@@ -37,7 +37,7 @@
    *         {@code null} is acceptable, and results in the server responding
    *         with a 404. This will hide the avatar image in the web UI.
    */
-  public String getUrl(IdentifiedUser forUser, int imageSize);
+  String getUrl(IdentifiedUser forUser, int imageSize);
 
   /**
    * Gets a URL for a user to modify their avatar image.
@@ -46,5 +46,5 @@
    * @return a URL the user should visit to modify their avatar, or null if
    *         modification is not possible.
    */
-  public String getChangeAvatarUrl(IdentifiedUser forUser);
+  String getChangeAvatarUrl(IdentifiedUser forUser);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
new file mode 100644
index 0000000..41a48af
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -0,0 +1,90 @@
+package com.google.gerrit.server.cache;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheStats;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.metrics.CallbackMetric;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import java.util.Set;
+
+@Singleton
+public class CacheMetrics {
+  @Inject
+  public CacheMetrics(MetricMaker metrics,
+      final DynamicMap<Cache<?, ?>> cacheMap) {
+    Field<String> F_NAME = Field.ofString("cache_name");
+
+    final CallbackMetric1<String, Long> memEnt =
+        metrics.newCallbackMetric("caches/memory_cached", Long.class,
+            new Description("Memory entries").setGauge().setUnit("entries"),
+            F_NAME);
+    final CallbackMetric1<String, Double> memHit =
+        metrics.newCallbackMetric("caches/memory_hit_ratio", Double.class,
+            new Description("Memory hit ratio").setGauge().setUnit("percent"),
+            F_NAME);
+    final CallbackMetric1<String, Long> memEvict =
+        metrics.newCallbackMetric("caches/memory_eviction_count", Long.class,
+            new Description("Memory eviction count").setGauge()
+                .setUnit("evicted entries"),
+            F_NAME);
+    final CallbackMetric1<String, Long> perDiskEnt =
+        metrics.newCallbackMetric("caches/disk_cached", Long.class,
+            new Description("Disk entries used by persistent cache").setGauge()
+                .setUnit("entries"),
+            F_NAME);
+    final CallbackMetric1<String, Double> perDiskHit =
+        metrics.newCallbackMetric("caches/disk_hit_ratio", Double.class,
+            new Description("Disk hit ratio for persistent cache").setGauge()
+                .setUnit("percent"),
+            F_NAME);
+
+    final Set<CallbackMetric<?>> cacheMetrics =
+        ImmutableSet.<CallbackMetric<?>> of(memEnt, memHit, memEvict,
+            perDiskEnt, perDiskHit);
+
+    metrics.newTrigger(cacheMetrics, new Runnable() {
+      @Override
+      public void run() {
+        for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+          Cache<?, ?> c = e.getProvider().get();
+          String name = metricNameOf(e);
+          CacheStats cstats = c.stats();
+          memEnt.set(name, c.size());
+          memHit.set(name, cstats.hitRate() * 100);
+          memEvict.set(name, cstats.evictionCount());
+          if (c instanceof PersistentCache) {
+            PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
+            perDiskEnt.set(name, d.size());
+            perDiskHit.set(name, hitRatio(d));
+          }
+        }
+        for (CallbackMetric<?> cbm : cacheMetrics) {
+          cbm.prune();
+        }
+      }
+    });
+  }
+
+  private static double hitRatio(PersistentCache.DiskStats d) {
+    if (d.requestCount() <= 0) {
+      return 100;
+    }
+    return ((double) d.hitCount() / d.requestCount() * 100);
+  }
+
+  private static String metricNameOf(DynamicMap.Entry<Cache<?, ?>> e) {
+    if ("gerrit".equals(e.getPluginName())) {
+      return e.getExportName();
+    } else {
+      return String.format("plugin/%s/%s", e.getPluginName(),
+          e.getExportName());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java
index 078f2dc..bdc1220 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java
@@ -17,7 +17,7 @@
 import com.google.common.cache.RemovalNotification;
 
 public interface CacheRemovalListener<K,V> {
-  public void onRemoval(String pluginName,
+  void onRemoval(String pluginName,
     String cacheName,
     RemovalNotification<K, V> notification);
-}
\ No newline at end of file
+}
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 f2d40c8..ff10351 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
@@ -82,7 +82,7 @@
       throws RestApiException, UpdateException, OrmException {
     ChangeControl control = req.getControl();
     IdentifiedUser caller = control.getUser().asIdentifiedUser();
-    if (!control.canAbandon()) {
+    if (!control.canAbandon(dbProvider.get())) {
       throw new AuthException("abandon not permitted");
     }
     Change change = abandon(control, input.message, caller.getAccount());
@@ -174,12 +174,18 @@
 
   @Override
   public UiAction.Description getDescription(ChangeResource resource) {
+    boolean canAbandon = false;
+    try {
+      canAbandon = resource.getControl().canAbandon(dbProvider.get());
+    } catch (OrmException e) {
+      log.error("Cannot check canAbandon status. Assuming false.", e);
+    }
     return new UiAction.Description()
       .setLabel("Abandon")
       .setTitle("Abandon the change")
       .setVisible(resource.getChange().getStatus().isOpen()
           && resource.getChange().getStatus() != Change.Status.DRAFT
-          && resource.getControl().canAbandon());
+          && canAbandon);
   }
 
   private static String status(Change change) {
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 cb3729b..2d06e93 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
@@ -177,7 +177,7 @@
       if (edit.isPresent()) {
         throw new ResourceConflictException(String.format(
             "edit already exists for the change %s",
-            resource.getChange().getChangeId()));
+            resource.getId()));
       }
       edit = createEdit();
       if (!Strings.isNullOrEmpty(path)) {
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 90d6aae..1217f34 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
@@ -21,6 +21,7 @@
 
 import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -46,7 +47,6 @@
 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.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.util.RequestScopePropagator;
@@ -228,7 +228,7 @@
 
   @Override
   public void updateRepo(RepoContext ctx)
-      throws InvalidChangeOperationException, IOException {
+      throws ResourceConflictException, IOException {
     validate(ctx);
     patchSetInfo = patchSetInfoFactory.get(
         ctx.getRevWalk(), commit, patchSet.getId());
@@ -250,6 +250,7 @@
     }
     db.patchSets().insert(Collections.singleton(patchSet));
     db.changes().insert(Collections.singleton(change));
+    update.setTopic(change.getTopic());
     LabelTypes labelTypes = ctl.getProjectControl().getLabelTypes();
     approvalsUtil.addReviewers(db, update, labelTypes, change,
         patchSet, patchSetInfo, reviewers, Collections.<Account.Id> emptySet());
@@ -307,7 +308,7 @@
   }
 
   private void validate(RepoContext ctx)
-      throws IOException, InvalidChangeOperationException {
+      throws IOException, ResourceConflictException {
     if (validatePolicy == CommitValidators.Policy.NONE) {
       return;
     }
@@ -339,7 +340,7 @@
         break;
       }
     } catch (CommitValidationException e) {
-      throw new InvalidChangeOperationException(e.getMessage());
+      throw new ResourceConflictException(e.getFullMessage());
     }
   }
 }
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 733d7a2..7d64591 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
@@ -90,10 +90,12 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gerrit.server.api.accounts.GpgApiAdapter;
 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.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
@@ -119,6 +121,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -140,7 +143,6 @@
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final MergeUtil.Factory mergeUtilFactory;
-  private final Submit submit;
   private final IdentifiedUser.GenericFactory userFactory;
   private final ChangeData.Factory changeDataFactory;
   private final FileInfoJson fileInfoJson;
@@ -166,7 +168,6 @@
       GitRepositoryManager repoManager,
       ProjectCache projectCache,
       MergeUtil.Factory mergeUtilFactory,
-      Submit submit,
       IdentifiedUser.GenericFactory uf,
       ChangeData.Factory cdf,
       FileInfoJson fileInfoJson,
@@ -187,7 +188,6 @@
     this.repoManager = repoManager;
     this.userFactory = uf;
     this.projectCache = projectCache;
-    this.submit = submit;
     this.mergeUtilFactory = mergeUtilFactory;
     this.fileInfoJson = fileInfoJson;
     this.accountLoaderFactory = ailf;
@@ -407,7 +407,7 @@
     // the response and avoid making a request to /submit_type from the UI.
     out.mergeable = in.getStatus() == Change.Status.MERGED
         ? null : cd.isMergeable();
-    out.submittable = submit.submittable(cd);
+    out.submittable = Submit.submittable(cd);
     ChangedLines changedLines = cd.changedLines();
     if (changedLines != null) {
       out.insertions = changedLines.insertions;
@@ -437,6 +437,13 @@
         out.permittedLabels = permittedLabels(ctl, cd);
       }
       out.removableReviewers = removableReviewers(ctl, out.labels.values());
+
+      out.reviewers = new HashMap<>();
+      for (Map.Entry<ReviewerStateInternal, Collection<Account.Id>> e
+          : cd.reviewers().asMap().entrySet()) {
+        out.reviewers.put(e.getKey().asReviewerState(),
+            toAccountInfo(e.getValue()));
+      }
     }
 
     boolean needMessages = has(MESSAGES);
@@ -828,6 +835,18 @@
     return result;
   }
 
+  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);
+  }
+
   private Map<String, RevisionInfo> revisions(ChangeControl ctl,
       Map<PatchSet.Id, PatchSet> map) throws PatchListNotAvailableException,
       GpgException, OrmException, IOException {
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 7b55b63..5148f9a 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
@@ -29,8 +29,8 @@
  * implementation changes, which might invalidate old entries).
  */
 public interface ChangeKindCache {
-  public ChangeKind getChangeKind(ProjectState project, Repository repo,
+  ChangeKind getChangeKind(ProjectState project, Repository repo,
       ObjectId prior, ObjectId next);
 
-  public ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch);
+  ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
index 03d189f..2a69d46 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
@@ -22,7 +22,9 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectState;
@@ -49,10 +51,22 @@
     return control;
   }
 
+  public IdentifiedUser getUser() {
+    return getControl().getUser().asIdentifiedUser();
+  }
+
+  public Change.Id getId() {
+    return getControl().getId();
+  }
+
   public Change getChange() {
     return getControl().getChange();
   }
 
+  public Project.NameKey getProject() {
+    return getChange().getProject();
+  }
+
   public ChangeNotes getNotes() {
     return getControl().getNotes();
   }
@@ -90,7 +104,7 @@
   public String getETag() {
     CurrentUser user = control.getUser();
     Hasher h = Hashing.md5().newHasher()
-        .putBoolean(user.getStarredChanges().contains(getChange().getId()));
+        .putBoolean(user.getStarredChanges().contains(getId()));
     prepareETag(h, user);
     return h.hash().toString();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
index 1648a5d..96f2e13 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.index.ChangeIndexer;
@@ -42,6 +43,7 @@
 public class ChangesCollection implements
     RestCollection<TopLevelResource, ChangeResource>,
     AcceptsPost<TopLevelResource> {
+  private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> user;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final Provider<QueryChanges> queryFactory;
@@ -52,6 +54,7 @@
 
   @Inject
   ChangesCollection(
+      Provider<ReviewDb> db,
       Provider<CurrentUser> user,
       ChangeControl.GenericFactory changeControlFactory,
       Provider<QueryChanges> queryFactory,
@@ -59,6 +62,7 @@
       ChangeUtil changeUtil,
       CreateChange createChange,
       ChangeIndexer changeIndexer) {
+    this.db = db;
     this.user = user;
     this.changeControlFactory = changeControlFactory;
     this.queryFactory = queryFactory;
@@ -81,8 +85,8 @@
   @Override
   public ChangeResource parse(TopLevelResource root, IdString id)
       throws ResourceNotFoundException, OrmException {
-    List<Change> changes = changeUtil.findChanges(id.encoded());
-    if (changes.isEmpty()) {
+    List<ChangeControl> ctls = changeUtil.findChanges(id.encoded(), user.get());
+    if (ctls.isEmpty()) {
       Integer changeId = Ints.tryParse(id.get());
       if (changeId != null) {
         try {
@@ -92,23 +96,35 @@
         }
       }
     }
-    if (changes.size() != 1) {
+    if (ctls.isEmpty()) {
       throw new ResourceNotFoundException(id);
     }
+    if (ctls.size() != 1) {
+      throw new ResourceNotFoundException("Multiple changes found for " + id);
+    }
 
-    ChangeControl control;
-    try {
-      control = changeControlFactory.validateFor(changes.get(0), user.get());
-    } catch (NoSuchChangeException e) {
+    ChangeControl ctl = ctls.get(0);
+    if (!ctl.isVisible(db.get())) {
       throw new ResourceNotFoundException(id);
     }
-    return new ChangeResource(control);
+    return new ChangeResource(ctl);
   }
 
   public ChangeResource parse(Change.Id id)
       throws ResourceNotFoundException, OrmException {
-    return parse(TopLevelResource.INSTANCE,
-        IdString.fromUrl(Integer.toString(id.get())));
+    try {
+      ChangeControl ctl = changeControlFactory.controlFor(id, user.get());
+      if (!ctl.isVisible(db.get())) {
+        throw new ResourceNotFoundException(toIdString(id));
+      }
+      return new ChangeResource(ctl);
+    } catch (NoSuchChangeException e) {
+      throw new ResourceNotFoundException(toIdString(id));
+    }
+  }
+
+  private static IdString toIdString(Change.Id id) {
+    return IdString.fromDecoded(id.toString());
   }
 
   public ChangeResource parse(ChangeControl control) {
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 ddeb5c0..349117f 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
@@ -45,8 +45,12 @@
 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.index.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
@@ -114,6 +118,9 @@
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final BatchUpdate.Factory updateFactory;
+  private final ChangeIndexer indexer;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeUpdate.Factory changeUpdateFactory;
 
   private FixInput fix;
   private Change change;
@@ -135,7 +142,10 @@
       ProjectControl.GenericFactory projectControlFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
-      BatchUpdate.Factory updateFactory) {
+      BatchUpdate.Factory updateFactory,
+      ChangeIndexer indexer,
+      ChangeControl.GenericFactory changeControlFactory,
+      ChangeUpdate.Factory changeUpdateFactory) {
     this.db = db;
     this.repoManager = repoManager;
     this.user = user;
@@ -144,6 +154,9 @@
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.updateFactory = updateFactory;
+    this.indexer = indexer;
+    this.changeControlFactory = changeControlFactory;
+    this.changeUpdateFactory = changeUpdateFactory;
     reset();
   }
 
@@ -507,9 +520,15 @@
               return c;
             }
           });
+      ChangeUpdate changeUpdate =
+          changeUpdateFactory.create(
+              changeControlFactory.controlFor(change, user.get()));
+      changeUpdate.fixStatus(Change.Status.MERGED);
+      changeUpdate.commit();
+      indexer.index(db.get(), change);
       p.status = Status.FIXED;
       p.outcome = "Marked change as merged";
-    } catch (OrmException e) {
+    } catch (OrmException | IOException | NoSuchChangeException e) {
       log.warn("Error marking " + change.getId() + "as merged", e);
       p.status = Status.FIX_FAILED;
       p.outcome = "Error updating status to merged";
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
index 3768738..576ae76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
@@ -44,6 +44,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.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectsCollection;
@@ -154,19 +155,19 @@
       ObjectId parentCommit;
       List<String> groups;
       if (input.baseChange != null) {
-        List<Change> changes = changeUtil.findChanges(input.baseChange);
-        if (changes.size() != 1) {
+        List<ChangeControl> ctls = changeUtil.findChanges(
+            input.baseChange, rsrc.getControl().getUser());
+        if (ctls.size() != 1) {
           throw new InvalidChangeOperationException(
               "Base change not found: " + input.baseChange);
         }
-        Change change = Iterables.getOnlyElement(changes);
-        if (!rsrc.getControl().controlFor(change).isVisible(db.get())) {
+        ChangeControl ctl = Iterables.getOnlyElement(ctls);
+        if (!ctl.isVisible(db.get())) {
           throw new InvalidChangeOperationException(
               "Base change not found: " + input.baseChange);
         }
-        PatchSet ps = db.get().patchSets().get(
-            new PatchSet.Id(change.getId(),
-            change.currentPatchSetId().get()));
+        PatchSet ps =
+            db.get().patchSets().get(ctl.getChange().currentPatchSetId());
         parentCommit = ObjectId.fromString(ps.getRevision().get());
         groups = ps.getGroups();
       } else {
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 a503721..0e7d96c 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
@@ -22,36 +22,39 @@
 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;
 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.Url;
 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.server.ReviewDb;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchLineCommentsUtil;
-import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.patch.PatchListCache;
 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;
-import java.sql.Timestamp;
 import java.util.Collections;
 
 @Singleton
 public class CreateDraftComment implements RestModifyView<RevisionResource, DraftInput> {
   private final Provider<ReviewDb> db;
-  private final ChangeUpdate.Factory updateFactory;
+  private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
   private final PatchLineCommentsUtil plcUtil;
   private final PatchListCache patchListCache;
 
   @Inject
   CreateDraftComment(Provider<ReviewDb> db,
-      ChangeUpdate.Factory updateFactory,
+      BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
       PatchLineCommentsUtil plcUtil,
       PatchListCache patchListCache) {
@@ -64,7 +67,7 @@
 
   @Override
   public Response<CommentInfo> apply(RevisionResource rsrc, DraftInput in)
-      throws BadRequestException, OrmException, IOException {
+      throws RestApiException, UpdateException, OrmException {
     if (Strings.isNullOrEmpty(in.path)) {
       throw new BadRequestException("path must be non-empty");
     } else if (in.message == null || in.message.trim().isEmpty()) {
@@ -75,24 +78,50 @@
       throw new BadRequestException("range endLine must be on the same line as the comment");
     }
 
-    int line = in.line != null
-        ? in.line
-        : in.range != null ? in.range.endLine : 0;
+    try (BatchUpdate bu = updateFactory.create(
+        db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      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));
+    }
+  }
 
-    Timestamp now = TimeUtil.nowTs();
-    ChangeUpdate update = updateFactory.create(rsrc.getControl(), now);
+  private class Op extends BatchUpdate.Op {
+    private final PatchSet.Id psId;
+    private final DraftInput in;
 
-    PatchLineComment c = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(rsrc.getPatchSet().getId(), in.path),
-            ChangeUtil.messageUUID(db.get())),
-        line, rsrc.getAccountId(), Url.decode(in.inReplyTo), now);
-    c.setSide(in.side == Side.PARENT ? (short) 0 : (short) 1);
-    c.setMessage(in.message.trim());
-    c.setRange(in.range);
-    setCommentRevId(c, patchListCache, rsrc.getChange(), rsrc.getPatchSet());
-    plcUtil.insertComments(db.get(), update, Collections.singleton(c));
-    update.commit();
-    return Response.created(commentJson.get().setFillAccounts(false).format(c));
+    private PatchLineComment comment;
+
+    private Op(PatchSet.Id psId, DraftInput in) {
+      this.psId = psId;
+      this.in = in;
+    }
+
+    @Override
+    public void updateChange(ChangeContext ctx)
+        throws ResourceNotFoundException, OrmException {
+      PatchSet ps = ctx.getDb().patchSets().get(psId);
+      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.getUser().getAccountId(), Url.decode(in.inReplyTo),
+          ctx.getWhen());
+      comment.setSide(in.side == Side.PARENT ? (short) 0 : (short) 1);
+      comment.setMessage(in.message.trim());
+      comment.setRange(in.range);
+      setCommentRevId(
+          comment, patchListCache, ctx.getChange(), ps);
+      plcUtil.insertComments(
+          ctx.getDb(), ctx.getChangeUpdate(), Collections.singleton(comment));
+    }
   }
 }
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/DeleteDraftChange.java
index b276aae..1dce829 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/DeleteDraftChange.java
@@ -14,19 +14,18 @@
 
 package com.google.gerrit.server.change;
 
-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.common.TimeUtil;
 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.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.DeleteDraftChange.Input;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.NoSuchChangeException;
+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;
@@ -34,50 +33,38 @@
 
 import org.eclipse.jgit.lib.Config;
 
-import java.io.IOException;
-
 @Singleton
 public class DeleteDraftChange implements
     RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   public static class Input {
   }
 
-  protected final Provider<ReviewDb> dbProvider;
-  private final ChangeUtil changeUtil;
+  private final Provider<ReviewDb> db;
+  private final BatchUpdate.Factory updateFactory;
+  private final Provider<DeleteDraftChangeOp> opProvider;
   private final boolean allowDrafts;
 
   @Inject
-  public DeleteDraftChange(Provider<ReviewDb> dbProvider,
-      ChangeUtil changeUtil,
+  public DeleteDraftChange(Provider<ReviewDb> db,
+      BatchUpdate.Factory updateFactory,
+      Provider<DeleteDraftChangeOp> opProvider,
       @GerritServerConfig Config cfg) {
-    this.dbProvider = dbProvider;
-    this.changeUtil = changeUtil;
-    this.allowDrafts = cfg.getBoolean("change", "allowDrafts", true);
+    this.db = db;
+    this.updateFactory = updateFactory;
+    this.opProvider = opProvider;
+    this.allowDrafts = DeleteDraftChangeOp.allowDrafts(cfg);
   }
 
   @Override
   public Response<?> apply(ChangeResource rsrc, Input input)
-      throws ResourceConflictException, AuthException,
-      ResourceNotFoundException, MethodNotAllowedException,
-      OrmException, IOException {
-    if (rsrc.getChange().getStatus() != Status.DRAFT) {
-      throw new ResourceConflictException("Change is not a draft");
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu = updateFactory.create(
+        db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Change.Id id = rsrc.getChange().getId();
+      bu.setOrder(BatchUpdate.Order.DB_BEFORE_REPO);
+      bu.addOp(id, opProvider.get());
+      bu.execute();
     }
-
-    if (!rsrc.getControl().canDeleteDraft(dbProvider.get())) {
-      throw new AuthException("Not permitted to delete this draft change");
-    }
-
-    if (!allowDrafts) {
-      throw new MethodNotAllowedException("draft workflow is disabled");
-    }
-
-    try {
-      changeUtil.deleteDraftChange(rsrc.getChange());
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage());
-    }
-
     return Response.none();
   }
 
@@ -85,11 +72,10 @@
   public UiAction.Description getDescription(ChangeResource rsrc) {
     try {
       return new UiAction.Description()
-        .setTitle(String.format("Delete draft change %d",
-            rsrc.getChange().getChangeId()))
+        .setTitle("Delete draft change " + rsrc.getId())
         .setVisible(allowDrafts
             && rsrc.getChange().getStatus() == Status.DRAFT
-            && rsrc.getControl().canDeleteDraft(dbProvider.get()));
+            && rsrc.getControl().canDeleteDraft(db.get()));
     } catch (OrmException e) {
       throw new IllegalStateException(e);
     }
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
new file mode 100644
index 0000000..41b9736
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
@@ -0,0 +1,112 @@
+// 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.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.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.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.Collections;
+import java.util.List;
+
+class DeleteDraftChangeOp extends BatchUpdate.Op {
+  static boolean allowDrafts(Config cfg) {
+    return cfg.getBoolean("change", "allowDrafts", true);
+  }
+
+  private final StarredChangesUtil starredChangesUtil;
+  private final boolean allowDrafts;
+
+  private Change.Id id;
+
+  @Inject
+  DeleteDraftChangeOp(StarredChangesUtil starredChangesUtil,
+      @GerritServerConfig Config cfg) {
+    this.starredChangesUtil = starredChangesUtil;
+    this.allowDrafts = allowDrafts(cfg);
+  }
+
+  @Override
+  public void updateChange(ChangeContext ctx)
+      throws RestApiException, OrmException {
+    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 = 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.getChangeControl().canDeleteDraft(ctx.getDb())) {
+      throw new AuthException("Not permitted to delete this draft change");
+    }
+    List<PatchSet> patchSets = ctx.getDb().patchSets().byChange(id).toList();
+    for (PatchSet ps : patchSets) {
+      if (!ps.isDraft()) {
+        throw new ResourceConflictException("Cannot delete draft change " + id
+            + ": patch set " + ps.getPatchSetId() + " is not a draft");
+      }
+      db.accountPatchReviews().delete(
+          db.accountPatchReviews().byPatchSet(ps.getId()));
+    }
+
+    // No need to delete from notedb; draft patch sets will be filtered out.
+    db.patchComments().delete(db.patchComments().byChange(id));
+
+    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
+    db.patchSets().delete(patchSets);
+    db.changeMessages().delete(db.changeMessages().byChange(id));
+    starredChangesUtil.unstarAll(id);
+    db.changes().delete(Collections.singleton(change));
+    ctx.markDeleted();
+  }
+
+  @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.getBatchRefUpdate().addCommand(
+          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 c4270a9..72b4209 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
@@ -16,37 +16,44 @@
 
 import static com.google.gerrit.server.PatchLineCommentsUtil.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.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.change.DeleteDraftComment.Input;
-import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.patch.PatchListCache;
 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;
 import java.util.Collections;
 
 @Singleton
-public class DeleteDraftComment implements RestModifyView<DraftCommentResource, Input> {
+public class DeleteDraftComment
+    implements RestModifyView<DraftCommentResource, Input> {
   static class Input {
   }
 
   private final Provider<ReviewDb> db;
   private final PatchLineCommentsUtil plcUtil;
-  private final ChangeUpdate.Factory updateFactory;
+  private final BatchUpdate.Factory updateFactory;
   private final PatchListCache patchListCache;
 
   @Inject
   DeleteDraftComment(Provider<ReviewDb> db,
       PatchLineCommentsUtil plcUtil,
-      ChangeUpdate.Factory updateFactory,
+      BatchUpdate.Factory updateFactory,
       PatchListCache patchListCache) {
     this.db = db;
     this.plcUtil = plcUtil;
@@ -56,13 +63,41 @@
 
   @Override
   public Response<CommentInfo> apply(DraftCommentResource rsrc, Input input)
-      throws OrmException, IOException {
-    ChangeUpdate update = updateFactory.create(rsrc.getControl());
-
-    PatchLineComment c = rsrc.getComment();
-    setCommentRevId(c, patchListCache, rsrc.getChange(), rsrc.getPatchSet());
-    plcUtil.deleteComments(db.get(), update, Collections.singleton(c));
-    update.commit();
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu = updateFactory.create(
+        db.get(), rsrc.getChange().getProject(), rsrc.getControl().getUser(),
+        TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getComment().getKey());
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+    }
     return Response.none();
   }
+
+  private class Op extends BatchUpdate.Op {
+    private final PatchLineComment.Key key;
+
+    private Op(PatchLineComment.Key key) {
+      this.key = key;
+    }
+
+    @Override
+    public void updateChange(ChangeContext ctx)
+        throws ResourceNotFoundException, OrmException {
+      Optional<PatchLineComment> maybeComment =
+          plcUtil.get(ctx.getDb(), ctx.getChangeNotes(), key);
+      if (!maybeComment.isPresent()) {
+        return; // Nothing to do.
+      }
+      PatchSet.Id psId = key.getParentKey().getParentKey();
+      PatchSet ps = ctx.getDb().patchSets().get(psId);
+      if (ps == null) {
+        throw new ResourceNotFoundException("patch set not found: " + psId);
+      }
+      PatchLineComment c = maybeComment.get();
+      setCommentRevId(c, patchListCache, ctx.getChange(), ps);
+      plcUtil.deleteComments(
+          ctx.getDb(), ctx.getChangeUpdate(), Collections.singleton(c));
+    }
+  }
 }
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 a266337..7edf483 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
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.TimeUtil;
 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.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
@@ -28,19 +30,23 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.DeleteDraftPatchSet.Input;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.ChangeIndexer;
+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.UpdateException;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.AtomicUpdate;
 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.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 import java.io.IOException;
+import java.util.Collections;
 
 @Singleton
 public class DeleteDraftPatchSet implements RestModifyView<RevisionResource, Input>,
@@ -48,131 +54,134 @@
   public static class Input {
   }
 
-  protected final Provider<ReviewDb> dbProvider;
+  private final Provider<ReviewDb> db;
+  private final BatchUpdate.Factory updateFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
-  private final ChangeUtil changeUtil;
-  private final ChangeIndexer indexer;
+  private final Provider<DeleteDraftChangeOp> deleteChangeOpProvider;
   private final boolean allowDrafts;
 
   @Inject
-  public DeleteDraftPatchSet(Provider<ReviewDb> dbProvider,
+  public DeleteDraftPatchSet(Provider<ReviewDb> db,
+      BatchUpdate.Factory updateFactory,
       PatchSetInfoFactory patchSetInfoFactory,
-      ChangeUtil changeUtil,
-      ChangeIndexer indexer,
+      Provider<DeleteDraftChangeOp> deleteChangeOpProvider,
       @GerritServerConfig Config cfg) {
-    this.dbProvider = dbProvider;
+    this.db = db;
+    this.updateFactory = updateFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
-    this.changeUtil = changeUtil;
-    this.indexer = indexer;
+    this.deleteChangeOpProvider = deleteChangeOpProvider;
     this.allowDrafts = cfg.getBoolean("change", "allowDrafts", true);
   }
 
   @Override
   public Response<?> apply(RevisionResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException,
-      ResourceConflictException, MethodNotAllowedException,
-      OrmException, IOException {
-    PatchSet patchSet = rsrc.getPatchSet();
-    PatchSet.Id patchSetId = patchSet.getId();
-    Change change = rsrc.getChange();
-
-    if (!patchSet.isDraft()) {
-      throw new ResourceConflictException("Patch set is not a draft");
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu = updateFactory.create(
+        db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.setOrder(BatchUpdate.Order.DB_BEFORE_REPO);
+      bu.addOp(rsrc.getChange().getId(), new Op(rsrc.getPatchSet().getId()));
+      bu.execute();
     }
-
-    if (!allowDrafts) {
-      throw new MethodNotAllowedException("draft workflow is disabled");
-    }
-
-    if (!rsrc.getControl().canDeleteDraft(dbProvider.get())) {
-      throw new AuthException("Not permitted to delete this draft patch set");
-    }
-
-    deleteDraftPatchSet(patchSet, change);
-    deleteOrUpdateDraftChange(patchSetId, change);
-
     return Response.none();
   }
 
+  private class Op extends BatchUpdate.Op {
+    private final PatchSet.Id psId;
+
+    private PatchSet patchSet;
+    private DeleteDraftChangeOp deleteChangeOp;
+
+    private Op(PatchSet.Id psId) {
+      this.psId = psId;
+    }
+
+    @Override
+    public void updateChange(ChangeContext ctx)
+        throws RestApiException, OrmException, IOException {
+      patchSet = ctx.getDb().patchSets().get(psId);
+      if (patchSet == null) {
+        return; // Nothing to do.
+      }
+      if (!patchSet.isDraft()) {
+        throw new ResourceConflictException("Patch set is not a draft");
+      }
+      if (!allowDrafts) {
+        throw new MethodNotAllowedException("Draft workflow is disabled");
+      }
+      if (!ctx.getChangeControl().canDeleteDraft(ctx.getDb())) {
+        throw new AuthException("Not permitted to delete this draft patch set");
+      }
+
+      deleteDraftPatchSet(patchSet, ctx);
+      deleteOrUpdateDraftChange(ctx);
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) throws IOException {
+      if (deleteChangeOp != null) {
+        deleteChangeOp.updateRepo(ctx);
+        return;
+      }
+      ctx.getBatchRefUpdate().addCommand(
+          new ReceiveCommand(
+              ObjectId.fromString(patchSet.getRevision().get()),
+              ObjectId.zeroId(),
+              patchSet.getRefName()));
+    }
+
+    private void deleteDraftPatchSet(PatchSet patchSet, ChangeContext ctx)
+        throws OrmException {
+      ReviewDb db = ctx.getDb();
+      db.accountPatchReviews().delete(db.accountPatchReviews().byPatchSet(psId));
+      db.changeMessages().delete(db.changeMessages().byPatchSet(psId));
+      // No need to delete from notedb; draft patch sets will be filtered out.
+      db.patchComments().delete(db.patchComments().byPatchSet(psId));
+      db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(psId));
+      db.patchSets().delete(Collections.singleton(patchSet));
+    }
+
+    private void deleteOrUpdateDraftChange(ChangeContext ctx)
+        throws OrmException, RestApiException {
+      Change c = ctx.getChange();
+      if (Iterables.isEmpty(ctx.getDb().patchSets().byChange(c.getId()))) {
+        deleteChangeOp = deleteChangeOpProvider.get();
+        deleteChangeOp.updateChange(ctx);
+        return;
+      }
+      if (c.currentPatchSetId().equals(psId)) {
+        c.setCurrentPatchSet(previousPatchSetInfo(ctx));
+      }
+      ChangeUtil.updated(c);
+      ctx.getDb().changes().update(Collections.singleton(c));
+    }
+
+    private PatchSetInfo previousPatchSetInfo(ChangeContext ctx)
+        throws OrmException {
+      try {
+        // TODO(dborowitz): Get this in a way that doesn't involve re-opening
+        // the repo after the updateRepo phase.
+        return patchSetInfoFactory.get(ctx.getDb(),
+            new PatchSet.Id(psId.getParentKey(), psId.get() - 1));
+      } catch (PatchSetInfoNotAvailableException e) {
+        throw new OrmException(e);
+      }
+    }
+  }
+
   @Override
   public UiAction.Description getDescription(RevisionResource rsrc) {
     try {
-      int psCount = dbProvider.get().patchSets()
+      int psCount = db.get().patchSets()
           .byChange(rsrc.getChange().getId()).toList().size();
       return new UiAction.Description()
         .setTitle(String.format("Delete draft revision %d",
             rsrc.getPatchSet().getPatchSetId()))
         .setVisible(allowDrafts
             && rsrc.getPatchSet().isDraft()
-            && rsrc.getControl().canDeleteDraft(dbProvider.get())
+            && rsrc.getControl().canDeleteDraft(db.get())
             && psCount > 1);
     } catch (OrmException e) {
       throw new IllegalStateException(e);
     }
   }
-
-  private void deleteDraftPatchSet(PatchSet patchSet, Change change)
-      throws ResourceNotFoundException, OrmException, IOException {
-    try {
-      changeUtil.deleteOnlyDraftPatchSet(patchSet, change);
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage());
-    }
-  }
-
-  private void deleteOrUpdateDraftChange(PatchSet.Id patchSetId,
-      Change change) throws OrmException, ResourceNotFoundException,
-      IOException {
-    if (dbProvider.get()
-            .patchSets()
-            .byChange(change.getId())
-            .toList().size() == 0) {
-      deleteDraftChange(change);
-    } else {
-      if (change.currentPatchSetId().equals(patchSetId)) {
-        updateChange(dbProvider.get(), change,
-            previousPatchSetInfo(patchSetId));
-      } else {
-        // TODO(davido): find a better way to enforce cache invalidation.
-        updateChange(dbProvider.get(), change, null);
-      }
-    }
-  }
-
-  private void deleteDraftChange(Change change)
-      throws OrmException, IOException, ResourceNotFoundException {
-    try {
-      changeUtil.deleteDraftChange(change);
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage());
-    }
-  }
-
-  private PatchSetInfo previousPatchSetInfo(PatchSet.Id patchSetId)
-      throws OrmException {
-    try {
-      return patchSetInfoFactory.get(dbProvider.get(),
-          new PatchSet.Id(patchSetId.getParentKey(),
-              patchSetId.get() - 1));
-    } catch (PatchSetInfoNotAvailableException e) {
-        throw new OrmException(e);
-    }
-  }
-
-  private void updateChange(final ReviewDb db,
-      Change change, final PatchSetInfo psInfo)
-      throws OrmException, IOException  {
-    change = db.changes().atomicUpdate(change.getId(),
-        new AtomicUpdate<Change>() {
-          @Override
-          public Change update(Change c) {
-            if (psInfo != null) {
-              c.setCurrentPatchSet(psInfo);
-            }
-            ChangeUtil.updated(c);
-            return c;
-          }
-        });
-    indexer.index(db, change);
-  }
 }
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 6c37252..b1eb630 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
@@ -75,7 +75,7 @@
       throws AuthException, ResourceNotFoundException, OrmException,
       IOException {
     ChangeControl control = rsrc.getControl();
-    Change.Id changeId = rsrc.getChange().getId();
+    Change.Id changeId = rsrc.getChangeId();
     ReviewDb db = dbProvider.get();
     ChangeUpdate update = updateFactory.create(rsrc.getControl());
 
@@ -103,13 +103,13 @@
       if (del.isEmpty()) {
         throw new ResourceNotFoundException();
       }
-      ChangeUtil.bumpRowVersionNotLastUpdatedOn(rsrc.getChange().getId(), db);
+      ChangeUtil.bumpRowVersionNotLastUpdatedOn(rsrc.getChangeId(), db);
       db.patchSetApprovals().delete(del);
-      update.removeReviewer(rsrc.getUser().getAccountId());
+      update.removeReviewer(rsrc.getReviewerUser().getAccountId());
 
       if (msg.length() > 0) {
         ChangeMessage changeMessage =
-            new ChangeMessage(new ChangeMessage.Key(rsrc.getChange().getId(),
+            new ChangeMessage(new ChangeMessage.Key(rsrc.getChangeId(),
                 ChangeUtil.messageUUID(db)),
                 control.getUser().getAccountId(),
                 TimeUtil.nowTs(), rsrc.getChange().currentPatchSetId());
@@ -136,9 +136,10 @@
 
   private Iterable<PatchSetApproval> approvals(ReviewDb db,
       ReviewerResource rsrc) throws OrmException {
-    final Account.Id user = rsrc.getUser().getAccountId();
+    final Account.Id user = rsrc.getReviewerUser().getAccountId();
     return Iterables.filter(
-        approvalsUtil.byChange(db, rsrc.getNotes()).values(),
+        approvalsUtil.byChange(db, rsrc.getChangeResource().getNotes())
+            .values(),
         new Predicate<PatchSetApproval>() {
           @Override
           public boolean apply(PatchSetApproval input) {
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
new file mode 100644
index 0000000..7392cdb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
@@ -0,0 +1,149 @@
+// 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.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+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.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+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.change.DeleteVote.Input;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+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;
+import com.google.inject.Singleton;
+
+import java.util.Collections;
+
+@Singleton
+public class DeleteVote implements RestModifyView<VoteResource, Input> {
+  public static class Input {
+  }
+
+  private final Provider<ReviewDb> db;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final IdentifiedUser.GenericFactory userFactory;
+
+  @Inject
+  DeleteVote(Provider<ReviewDb> db,
+      BatchUpdate.Factory batchUpdateFactory,
+      ApprovalsUtil approvalsUtil,
+      ChangeMessagesUtil cmUtil,
+      IdentifiedUser.GenericFactory userFactory) {
+    this.db = db;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.cmUtil = cmUtil;
+    this.userFactory = userFactory;
+  }
+
+  @Override
+  public Response<?> apply(VoteResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    ReviewerResource r = rsrc.getReviewer();
+    Change change = r.getChange();
+    try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
+          change.getProject(), r.getControl().getUser(), TimeUtil.nowTs())) {
+      bu.addOp(change.getId(),
+          new Op(r.getReviewerUser().getAccountId(), rsrc.getLabel()));
+      bu.execute();
+    }
+
+    return Response.none();
+  }
+
+  private class Op extends BatchUpdate.Op {
+    private final Account.Id accountId;
+    private final String label;
+
+    private Op(Account.Id accountId, String label) {
+      this.accountId = accountId;
+      this.label = label;
+    }
+
+    @Override
+    public void updateChange(ChangeContext ctx)
+        throws OrmException, AuthException, ResourceNotFoundException {
+      IdentifiedUser user = ctx.getUser().asIdentifiedUser();
+      Change change = ctx.getChange();
+      ChangeControl ctl = ctx.getChangeControl();
+      PatchSet.Id psId = change.currentPatchSetId();
+
+      PatchSetApproval psa = null;
+      StringBuilder msg = new StringBuilder();
+      for (PatchSetApproval a : approvalsUtil.byPatchSetUser(
+            ctx.getDb(), ctl, psId, accountId)) {
+        if (ctl.canRemoveReviewer(a)) {
+          if (a.getLabel().equals(label)) {
+            msg.append("Removed ")
+                .append(a.getLabel()).append(formatLabelValue(a.getValue()))
+                .append(" by ").append(userFactory.create(user.getAccountId())
+                    .getNameEmail())
+                .append("\n");
+            psa = a;
+            a.setValue((short)0);
+            ctx.getChangeUpdate().setPatchSetId(psId);
+            ctx.getChangeUpdate().removeApproval(label);
+            break;
+          }
+        } else {
+          throw new AuthException("delete not permitted");
+        }
+      }
+      if (psa == null) {
+        throw new ResourceNotFoundException();
+      }
+      ChangeUtil.bumpRowVersionNotLastUpdatedOn(change.getId(), ctx.getDb());
+      ctx.getDb().patchSetApprovals().update(Collections.singleton(psa));
+
+      if (msg.length() > 0) {
+        ChangeMessage changeMessage =
+            new ChangeMessage(new ChangeMessage.Key(change.getId(),
+                ChangeUtil.messageUUID(ctx.getDb())),
+                user.getAccountId(),
+                ctx.getWhen(),
+                change.currentPatchSetId());
+        changeMessage.setMessage(msg.toString());
+        cmUtil.addChangeMessage(ctx.getDb(), ctx.getChangeUpdate(),
+            changeMessage);
+      }
+    }
+  }
+
+  private static String formatLabelValue(short value) {
+    if (value > 0) {
+      return "+" + value;
+    } else {
+      return Short.toString(value);
+    }
+  }
+}
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 71974c4..e31b11f 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
@@ -64,6 +64,7 @@
           ? e.getChangeType().getCode() : null;
       d.oldPath = e.getOldName();
       d.sizeDelta = e.getSizeDelta();
+      d.size = e.getSize();
       if (e.getPatchType() == Patch.PatchType.BINARY) {
         d.binary = true;
       } else {
@@ -78,6 +79,7 @@
         // a single record with data from both sides.
         d.status = Patch.ChangeType.REWRITE.getCode();
         d.sizeDelta = o.sizeDelta;
+        d.size = o.size;
         if (o.binary != null && o.binary) {
           d.binary = true;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java
index 547a500..c099e43 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java
@@ -39,9 +39,9 @@
     }
   }
 
-  public boolean get(ObjectId commit, Ref intoRef, SubmitType submitType,
+  boolean get(ObjectId commit, Ref intoRef, SubmitType submitType,
       String mergeStrategy, Branch.NameKey dest, Repository repo, ReviewDb db);
 
-  public Boolean getIfPresent(ObjectId commit, Ref intoRef,
+  Boolean getIfPresent(ObjectId commit, Ref intoRef,
       SubmitType submitType, String mergeStrategy);
 }
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 9c588ed..0ef8b51 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.VoteResource.VOTE_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
@@ -37,6 +38,7 @@
     bind(DraftComments.class);
     bind(Comments.class);
     bind(Files.class);
+    bind(Votes.class);
 
     DynamicMap.mapOf(binder(), CHANGE_KIND);
     DynamicMap.mapOf(binder(), COMMENT_KIND);
@@ -45,6 +47,7 @@
     DynamicMap.mapOf(binder(), REVIEWER_KIND);
     DynamicMap.mapOf(binder(), REVISION_KIND);
     DynamicMap.mapOf(binder(), CHANGE_EDIT_KIND);
+    DynamicMap.mapOf(binder(), VOTE_KIND);
 
     get(CHANGE_KIND).to(GetChange.class);
     get(CHANGE_KIND, "detail").to(GetDetail.class);
@@ -73,6 +76,8 @@
     child(CHANGE_KIND, "reviewers").to(Reviewers.class);
     get(REVIEWER_KIND).to(GetReviewer.class);
     delete(REVIEWER_KIND).to(DeleteReviewer.class);
+    child(REVIEWER_KIND, "votes").to(Votes.class);
+    delete(VOTE_KIND).to(DeleteVote.class);
 
     child(CHANGE_KIND, "revisions").to(Revisions.class);
     get(REVISION_KIND, "actions").to(GetRevisionActions.class);
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 c34fb43..9a72b07 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
@@ -17,6 +17,8 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
 import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.ChangeHooks;
@@ -42,7 +44,7 @@
 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.notedb.ReviewerState;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ChangeModifiedException;
@@ -106,7 +108,7 @@
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
   private ChangeMessage changeMessage;
-  private SetMultimap<ReviewerState, Account.Id> oldReviewers;
+  private SetMultimap<ReviewerStateInternal, Account.Id> oldReviewers;
 
   @AssistedInject
   public PatchSetInserter(ChangeHooks hooks,
@@ -235,8 +237,7 @@
 
     if (message != null) {
       changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(
-              ctl.getChange().getId(), ChangeUtil.messageUUID(db)),
+          new ChangeMessage.Key(ctl.getId(), ChangeUtil.messageUUID(db)),
           ctx.getUser().getAccountId(), ctx.getWhen(), patchSet.getId());
       changeMessage.setMessage(message);
     }
@@ -280,8 +281,8 @@
         cm.setFrom(ctx.getUser().getAccountId());
         cm.setPatchSet(patchSet, patchSetInfo);
         cm.setChangeMessage(changeMessage);
-        cm.addReviewers(oldReviewers.get(ReviewerState.REVIEWER));
-        cm.addExtraCC(oldReviewers.get(ReviewerState.CC));
+        cm.addReviewers(oldReviewers.get(REVIEWER));
+        cm.addExtraCC(oldReviewers.get(CC));
         cm.send();
       } catch (Exception err) {
         log.error("Cannot send email for new patch set on change "
@@ -328,7 +329,7 @@
         break;
       }
     } catch (CommitValidationException e) {
-      throw new ResourceConflictException(e.getMessage());
+      throw new ResourceConflictException(e.getFullMessage());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
index a3fc2e1..6d7720d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
@@ -52,7 +52,7 @@
           req.getChange().getProject(), req.getControl().getUser(),
           TimeUtil.nowTs())) {
       SetHashtagsOp op = hashtagsFactory.create(input);
-      bu.addOp(req.getChange().getId(), op);
+      bu.addOp(req.getId(), op);
       bu.execute();
       return Response.<ImmutableSortedSet<String>> ok(op.getUpdatedHashtags());
     }
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 e2473e7..f2d0ffa 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
@@ -17,6 +17,7 @@
 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.notedb.ReviewerStateInternal.REVIEWER;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
@@ -349,7 +350,8 @@
     }
 
     @Override
-    public void updateChange(ChangeContext ctx) throws OrmException {
+    public void updateChange(ChangeContext ctx)
+        throws OrmException, ResourceConflictException {
       user = ctx.getUser().asIdentifiedUser();
       change = ctx.getChange();
       if (change.getLastUpdatedOn().before(ctx.getWhen())) {
@@ -500,7 +502,8 @@
       return drafts;
     }
 
-    private boolean updateLabels(ChangeContext ctx) throws OrmException {
+    private boolean updateLabels(ChangeContext ctx)
+        throws OrmException, ResourceConflictException {
       Map<String, Short> labels = in.labels;
       if (labels == null) {
         labels = Collections.emptyMap();
@@ -515,10 +518,6 @@
       for (Map.Entry<String, Short> ent : labels.entrySet()) {
         String name = ent.getKey();
         LabelType lt = checkNotNull(labelTypes.byLabel(name), name);
-        if (ctx.getChange().getStatus().isClosed()) {
-          // TODO Allow updating some labels even when closed.
-          continue;
-        }
 
         PatchSetApproval c = current.remove(lt.getName());
         String normName = lt.getName();
@@ -550,10 +549,16 @@
           ups.add(c);
           addLabelDelta(normName, c.getValue());
           categories.put(normName, c.getValue());
+          update.putReviewer(user.getAccountId(), REVIEWER);
           update.putApproval(ent.getKey(), ent.getValue());
         }
       }
 
+      if (!del.isEmpty() || !ups.isEmpty()) {
+        if (ctx.getChange().getStatus().isClosed()) {
+          throw new ResourceConflictException("change is closed");
+        }
+      }
       forceCallerAsReviewer(ctx, current, ups, del);
       ctx.getDb().patchSetApprovals().delete(del);
       ctx.getDb().patchSetApprovals().upsert(ups);
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 3ab84ab..3330db5 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
@@ -149,11 +149,12 @@
 
   private PostResult putAccount(ReviewerResource rsrc) throws OrmException,
       IOException {
-    Account member = rsrc.getUser().getAccount();
-    ChangeControl control = rsrc.getControl();
+    Account member = rsrc.getReviewerUser().getAccount();
+    ChangeControl control = rsrc.getReviewerControl();
     PostResult result = new PostResult();
     if (isValidReviewer(member, control)) {
-      addReviewers(rsrc, result, ImmutableMap.of(member.getId(), control));
+      addReviewers(rsrc.getChangeResource(), result,
+          ImmutableMap.of(member.getId(), control));
     }
     return result;
   }
@@ -230,9 +231,9 @@
     ReviewDb db = dbProvider.get();
     ChangeUpdate update = updateFactory.create(rsrc.getControl());
     List<PatchSetApproval> added;
-    db.changes().beginTransaction(rsrc.getChange().getId());
+    db.changes().beginTransaction(rsrc.getId());
     try {
-      ChangeUtil.bumpRowVersionNotLastUpdatedOn(rsrc.getChange().getId(), db);
+      ChangeUtil.bumpRowVersionNotLastUpdatedOn(rsrc.getId(), db);
       added = approvalsUtil.addReviewers(db, rsrc.getNotes(), update,
           rsrc.getControl().getLabelTypes(), rsrc.getChange(),
           reviewers.keySet());
@@ -243,7 +244,7 @@
 
     update.commit();
     CheckedFuture<?, IOException> indexFuture =
-        indexer.indexAsync(rsrc.getChange().getId());
+        indexer.indexAsync(rsrc.getId());
     result.reviewers = Lists.newArrayListWithCapacity(added.size());
     for (PatchSetApproval psa : added) {
       // New reviewers have value 0, don't bother normalizing.
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 a4a5e16..fe4744e 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
@@ -16,26 +16,31 @@
 
 import static com.google.gerrit.server.PatchLineCommentsUtil.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.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+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.extensions.restapi.Url;
 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.server.ReviewDb;
 import com.google.gerrit.server.PatchLineCommentsUtil;
-import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.patch.PatchListCache;
 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;
 import java.util.Collections;
 
 @Singleton
@@ -44,7 +49,7 @@
   private final Provider<ReviewDb> db;
   private final DeleteDraftComment delete;
   private final PatchLineCommentsUtil plcUtil;
-  private final ChangeUpdate.Factory updateFactory;
+  private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
   private final PatchListCache patchListCache;
 
@@ -52,7 +57,7 @@
   PutDraftComment(Provider<ReviewDb> db,
       DeleteDraftComment delete,
       PatchLineCommentsUtil plcUtil,
-      ChangeUpdate.Factory updateFactory,
+      BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
       PatchListCache patchListCache) {
     this.db = db;
@@ -65,9 +70,7 @@
 
   @Override
   public Response<CommentInfo> apply(DraftCommentResource rsrc, DraftInput in) throws
-      BadRequestException, OrmException, IOException {
-    PatchLineComment c = rsrc.getComment();
-    ChangeUpdate update = updateFactory.create(rsrc.getControl());
+      RestApiException, UpdateException, OrmException {
     if (in == null || in.message == null || in.message.trim().isEmpty()) {
       return delete.apply(rsrc, null);
     } else if (in.id != null && !rsrc.getId().equals(in.id)) {
@@ -78,34 +81,74 @@
       throw new BadRequestException("range endLine must be on the same line as the comment");
     }
 
-    if (in.path != null
-        && !in.path.equals(c.getKey().getParentKey().getFileName())) {
-      // Updating the path alters the primary key, which isn't possible.
-      // Delete then recreate the comment instead of an update.
-
-      plcUtil.deleteComments(db.get(), update, Collections.singleton(c));
-      c = new PatchLineComment(
-          new PatchLineComment.Key(
-              new Patch.Key(rsrc.getPatchSet().getId(), in.path),
-              c.getKey().get()),
-          c.getLine(),
-          rsrc.getAuthorId(),
-          c.getParentUuid(), TimeUtil.nowTs());
-      setCommentRevId(c, patchListCache, rsrc.getChange(), rsrc.getPatchSet());
-      plcUtil.insertComments(db.get(), update,
-          Collections.singleton(update(c, in)));
-    } else {
-      if (c.getRevId() == null) {
-        setCommentRevId(c, patchListCache, rsrc.getChange(), rsrc.getPatchSet());
-      }
-      plcUtil.updateComments(db.get(), update,
-          Collections.singleton(update(c, in)));
+    try (BatchUpdate bu = updateFactory.create(
+        db.get(), rsrc.getChange().getProject(), rsrc.getControl().getUser(),
+        TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getComment().getKey(), in);
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+      return Response.ok(
+          commentJson.get().setFillAccounts(false).format(op.comment));
     }
-    update.commit();
-    return Response.ok(commentJson.get().setFillAccounts(false).format(c));
   }
 
-  private PatchLineComment update(PatchLineComment e, DraftInput in) {
+  private class Op extends BatchUpdate.Op {
+    private final PatchLineComment.Key key;
+    private final DraftInput in;
+
+    private PatchLineComment comment;
+
+    private Op(PatchLineComment.Key key, DraftInput in) {
+      this.key = key;
+      this.in = in;
+    }
+
+    @Override
+    public void updateChange(ChangeContext ctx)
+        throws ResourceNotFoundException, OrmException {
+      Optional<PatchLineComment> maybeComment =
+          plcUtil.get(ctx.getDb(), ctx.getChangeNotes(), 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);
+      }
+      comment = maybeComment.get();
+
+      PatchSet.Id psId = comment.getKey().getParentKey().getParentKey();
+      PatchSet ps = ctx.getDb().patchSets().get(psId);
+      if (ps == null) {
+        throw new ResourceNotFoundException("patch set not found: " + psId);
+      }
+      if (in.path != null
+          && !in.path.equals(comment.getKey().getParentKey().getFileName())) {
+        // Updating the path alters the primary key, which isn't possible.
+        // Delete then recreate the comment instead of an update.
+
+        plcUtil.deleteComments(
+            ctx.getDb(), ctx.getChangeUpdate(), Collections.singleton(comment));
+        comment = new PatchLineComment(
+            new PatchLineComment.Key(
+                new Patch.Key(psId, in.path),
+                comment.getKey().get()),
+            comment.getLine(),
+            ctx.getUser().getAccountId(),
+            comment.getParentUuid(), ctx.getWhen());
+        setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+        plcUtil.insertComments(ctx.getDb(), ctx.getChangeUpdate(),
+            Collections.singleton(update(comment, in)));
+      } else {
+        if (comment.getRevId() == null) {
+          setCommentRevId(
+              comment, patchListCache, ctx.getChange(), ps);
+        }
+        plcUtil.updateComments(ctx.getDb(), ctx.getChangeUpdate(),
+            Collections.singleton(update(comment, in)));
+      }
+    }
+  }
+
+  private static PatchLineComment update(PatchLineComment e, DraftInput in) {
     if (in.side != null) {
       e.setSide(in.side == Side.PARENT ? (short) 0 : (short) 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 1061d4b..105654b 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
@@ -77,7 +77,7 @@
     Op op = new Op(ctl, input != null ? input : new Input());
     try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
         req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getChange().getId(), op);
+      u.addOp(req.getId(), op);
       u.execute();
     }
     return Strings.isNullOrEmpty(op.newTopicName)
@@ -101,8 +101,8 @@
     @Override
     public void updateChange(ChangeContext ctx) throws OrmException {
       change = ctx.getChange();
-      String newTopicName = Strings.nullToEmpty(input.topic);
-      String oldTopicName = Strings.nullToEmpty(change.getTopic());
+      newTopicName = Strings.nullToEmpty(input.topic);
+      oldTopicName = Strings.nullToEmpty(change.getTopic());
       if (oldTopicName.equals(newTopicName)) {
         return;
       }
@@ -116,6 +116,7 @@
             oldTopicName, newTopicName);
       }
       change.setTopic(Strings.emptyToNull(newTopicName));
+      ctx.getChangeUpdate().setTopic(change.getTopic());
       ChangeUtil.updated(change);
       ctx.getDb().changes().update(Collections.singleton(change));
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
index 60f285f..5bcd23c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
@@ -90,7 +90,7 @@
         ObjectInserter oi = repo.newObjectInserter();
         BatchUpdate bu = updateFactory.create(dbProvider.get(),
           change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      if (!control.canRebase()) {
+      if (!control.canRebase(dbProvider.get())) {
         throw new AuthException("rebase not permitted");
       } else if (!change.getStatus().isOpen()) {
         throw new ResourceConflictException("change is "
@@ -209,9 +209,15 @@
   public UiAction.Description getDescription(RevisionResource resource) {
     PatchSet patchSet = resource.getPatchSet();
     Branch.NameKey dest = resource.getChange().getDest();
+    boolean canRebase = false;
+    try {
+      canRebase = resource.getControl().canRebase(dbProvider.get());
+    } catch (OrmException e) {
+      log.error("Cannot check canRebase status. Assuming false.", e);
+    }
     boolean visible = resource.getChange().getStatus().isOpen()
           && resource.isCurrent()
-          && resource.getControl().canRebase();
+          && canRebase;
     boolean enabled = true;
 
     if (visible) {
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 5b0eb6d..421fced 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
@@ -80,14 +80,14 @@
   public ChangeInfo apply(ChangeResource req, RestoreInput input)
       throws RestApiException, UpdateException, OrmException {
     ChangeControl ctl = req.getControl();
-    if (!ctl.canRestore()) {
+    if (!ctl.canRestore(dbProvider.get())) {
       throw new AuthException("restore not permitted");
     }
 
     Op op = new Op(input);
     try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
         req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getChange().getId(), op).execute();
+      u.addOp(req.getId(), op).execute();
     }
     return json.create(ChangeJson.NO_OPTIONS).format(op.change);
   }
@@ -160,11 +160,17 @@
 
   @Override
   public UiAction.Description getDescription(ChangeResource resource) {
+    boolean canRestore = false;
+    try {
+      canRestore = resource.getControl().canRestore(dbProvider.get());
+    } catch (OrmException e) {
+      log.error("Cannot check canRestore status. Assuming false.", e);
+    }
     return new UiAction.Description()
       .setLabel("Restore")
       .setTitle("Restore the change")
       .setVisible(resource.getChange().getStatus() == Status.ABANDONED
-          && resource.getControl().canRestore());
+          && canRestore);
   }
 
   private static String status(Change change) {
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 dc2ed5d..7e3845c 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
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.RefControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -59,9 +59,9 @@
   public ChangeInfo apply(ChangeResource req, RevertInput input)
       throws IOException, OrmException, RestApiException,
       UpdateException {
-    ChangeControl control = req.getControl();
+    RefControl refControl = req.getControl().getRefControl();
     Change change = req.getChange();
-    if (!control.canAddPatchSet()) {
+    if (!refControl.canUpload()) {
       throw new AuthException("revert not permitted");
     } else if (change.getStatus() != Status.MERGED) {
       throw new ResourceConflictException("change is " + status(change));
@@ -69,7 +69,7 @@
 
     Change.Id revertedChangeId;
     try {
-      revertedChangeId = changeUtil.revert(control,
+      revertedChangeId = changeUtil.revert(req.getControl(),
             change.currentPatchSetId(),
             Strings.emptyToNull(input.message),
             new PersonIdent(myIdent, TimeUtil.nowTs()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
index 7c10b11..ec0f937 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -67,8 +67,8 @@
     AccountLoader loader = accountLoaderFactory.create(true);
     for (ReviewerResource rsrc : rsrcs) {
       ReviewerInfo info = format(new ReviewerInfo(
-          rsrc.getUser().getAccountId()),
-          rsrc.getUserControl());
+          rsrc.getReviewerUser().getAccountId()),
+          rsrc.getReviewerControl());
       loader.put(info);
       infos.add(info);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
index f7b5228..d48151b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -14,48 +14,64 @@
 
 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.Change;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
-public class ReviewerResource extends ChangeResource {
+public class ReviewerResource implements RestResource {
   public static final TypeLiteral<RestView<ReviewerResource>> REVIEWER_KIND =
       new TypeLiteral<RestView<ReviewerResource>>() {};
 
   public static interface Factory {
-    ReviewerResource create(ChangeResource rsrc, IdentifiedUser user);
-    ReviewerResource create(ChangeResource rsrc, Account.Id id);
+    ReviewerResource create(ChangeResource change, Account.Id id);
   }
 
+  private final ChangeResource change;
   private final IdentifiedUser user;
 
   @AssistedInject
-  ReviewerResource(@Assisted ChangeResource rsrc,
-      @Assisted IdentifiedUser user) {
-    super(rsrc);
-    this.user = user;
-  }
-
-  @AssistedInject
   ReviewerResource(IdentifiedUser.GenericFactory userFactory,
-      @Assisted ChangeResource rsrc,
+      @Assisted ChangeResource change,
       @Assisted Account.Id id) {
-    this(rsrc, userFactory.create(id));
+    this.change = change;
+    this.user = userFactory.create(id);
   }
 
-  public IdentifiedUser getUser() {
+  public ChangeResource getChangeResource() {
+    return change;
+  }
+
+  public Change.Id getChangeId() {
+    return change.getId();
+  }
+
+  public Change getChange() {
+    return change.getChange();
+  }
+
+  public IdentifiedUser getReviewerUser() {
     return user;
   }
 
   /**
+   * @return the control for the caller's user (as opposed to the reviewer's
+   *     user as returned by {@link #getReviewerControl()}).
+   */
+  public ChangeControl getControl() {
+    return change.getControl();
+  }
+
+  /**
    * @return the control for the reviewer's user (as opposed to the caller's
    *     user as returned by {@link #getControl()}).
    */
-  public ChangeControl getUserControl() {
-    return getControl().forUser(user);
+  public ChangeControl getReviewerControl() {
+    return change.getControl().forUser(user);
   }
 }
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 6731dd9..685ff7b 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
@@ -21,6 +21,7 @@
 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.server.IdentifiedUser;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -63,6 +64,10 @@
     return getControl().getChange();
   }
 
+  public Project.NameKey getProject() {
+    return getChange().getProject();
+  }
+
   public ChangeNotes getNotes() {
     return getChangeResource().getNotes();
   }
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 bb5775b..734ebb6 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
@@ -129,7 +129,7 @@
       // Chance of collision rises; look at all patch sets on the change.
       List<RevisionResource> out = Lists.newArrayList();
       for (PatchSet ps : dbProvider.get().patchSets()
-          .byChange(change.getChange().getId())) {
+          .byChange(change.getId())) {
         if (ps.getRevision() != null && ps.getRevision().get().startsWith(id)) {
           out.add(new RevisionResource(change, ps));
         }
@@ -141,7 +141,7 @@
   private List<RevisionResource> byLegacyPatchSetId(ChangeResource change,
       String id) throws OrmException {
     PatchSet ps = dbProvider.get().patchSets().get(new PatchSet.Id(
-        change.getChange().getId(),
+        change.getId(),
         Integer.parseInt(id)));
     if (ps != null) {
       return Collections.singletonList(new RevisionResource(change, ps));
@@ -161,8 +161,7 @@
       throws AuthException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(change.getChange());
     if (edit.isPresent()) {
-      PatchSet ps = new PatchSet(new PatchSet.Id(
-          change.getChange().getId(), 0));
+      PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), 0));
       ps.setRevision(edit.get().getRevision());
       if (revid == null || edit.get().getRevision().equals(revid)) {
         return Collections.singletonList(
@@ -174,7 +173,7 @@
 
   private static List<RevisionResource> toResources(final ChangeResource change,
       Iterable<PatchSet> patchSets) {
-    final Change.Id changeId = change.getChange().getId();
+    final Change.Id changeId = change.getId();
     return FluentIterable.from(patchSets)
         .filter(new Predicate<PatchSet>() {
           @Override
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 1a92ec0..d22b1dd 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
@@ -259,7 +259,7 @@
    * @param cd the change to check for submittability
    * @return if the change has any problems for submission
    */
-  public boolean submittable(ChangeData cd) {
+  public static boolean submittable(ChangeData cd) {
     try {
       MergeOp.checkSubmitRule(cd);
       return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/VoteResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/VoteResource.java
new file mode 100644
index 0000000..4dfaff0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/VoteResource.java
@@ -0,0 +1,40 @@
+// 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.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class VoteResource implements RestResource {
+  public static final TypeLiteral<RestView<VoteResource>> VOTE_KIND =
+      new TypeLiteral<RestView<VoteResource>>() {};
+
+  private final ReviewerResource reviewer;
+  private final String label;
+
+  public VoteResource(ReviewerResource reviewer, String label) {
+    this.reviewer = reviewer;
+    this.label = label;
+  }
+
+  public ReviewerResource getReviewer() {
+    return reviewer;
+  }
+
+  public String getLabel() {
+    return label;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
new file mode 100644
index 0000000..3bba37e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
@@ -0,0 +1,89 @@
+// 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.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+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.RestReadView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.Map;
+import java.util.TreeMap;
+
+@Singleton
+public class Votes implements ChildCollection<ReviewerResource, VoteResource> {
+  private final DynamicMap<RestView<VoteResource>> views;
+  private final List list;
+
+  @Inject
+  Votes(DynamicMap<RestView<VoteResource>> views,
+      List list) {
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public DynamicMap<RestView<VoteResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<ReviewerResource> list() throws AuthException {
+    return list;
+  }
+
+  @Override
+  public VoteResource parse(ReviewerResource reviewer, IdString id)
+      throws ResourceNotFoundException, OrmException, AuthException {
+    return new VoteResource(reviewer, id.get());
+  }
+
+  @Singleton
+  public static class List implements RestReadView<ReviewerResource> {
+    private final Provider<ReviewDb> db;
+    private final ApprovalsUtil approvalsUtil;
+
+    @Inject
+    List(Provider<ReviewDb> db,
+        ApprovalsUtil approvalsUtil) {
+      this.db = db;
+      this.approvalsUtil = approvalsUtil;
+    }
+
+    @Override
+    public Map<String, Short> apply(ReviewerResource rsrc) throws OrmException {
+      Map<String, Short> votes = new TreeMap<>();
+      Iterable<PatchSetApproval> byPatchSetUser = approvalsUtil.byPatchSetUser(
+          db.get(),
+          rsrc.getControl(),
+          rsrc.getChange().currentPatchSetId(),
+          rsrc.getReviewerUser().getAccountId());
+      for (PatchSetApproval psa : byPatchSetUser) {
+        votes.put(psa.getLabel(), psa.getValue());
+      }
+      return votes;
+    }
+  }
+}
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 ca4a9d2..a662328 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
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.InternalAuthBackend;
 import com.google.gerrit.server.auth.ldap.LdapModule;
+import com.google.gerrit.server.auth.oauth.OAuthRealm;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 
@@ -42,6 +43,10 @@
         install(new LdapModule());
         break;
 
+      case OAUTH:
+        bind(Realm.class).to(OAuthRealm.class);
+        break;
+
       case CUSTOM_EXTENSION:
         break;
 
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 ca3afad..564e0fb 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
@@ -19,6 +19,7 @@
 import com.google.common.cache.Cache;
 import com.google.gerrit.audit.AuditModule;
 import com.google.gerrit.common.EventListener;
+import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.CloneCommand;
 import com.google.gerrit.extensions.config.DownloadCommand;
@@ -75,6 +76,7 @@
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.events.EventFactory;
+import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.EmailMerge;
@@ -124,6 +126,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionSortCache;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.tools.ToolsCatalog;
@@ -270,6 +273,7 @@
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterUpdate.class);
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
         .to(ProjectConfigEntry.UpdateChecker.class);
+    DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
     DynamicSet.setOf(binder(), EventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
@@ -293,10 +297,13 @@
     DynamicSet.setOf(binder(), DiffWebLink.class);
     DynamicSet.setOf(binder(), ProjectWebLink.class);
     DynamicSet.setOf(binder(), BranchWebLink.class);
+    DynamicMap.mapOf(binder(), OAuthLoginProvider.class);
 
     factory(UploadValidators.Factory.class);
     DynamicSet.setOf(binder(), UploadValidationListener.class);
 
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
+
     bind(AnonymousUser.class);
 
     factory(CommitValidators.Factory.class);
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 9eca842..e5ac370 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
@@ -35,6 +35,7 @@
 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.inject.Inject;
 
 import org.eclipse.jgit.lib.Config;
@@ -64,6 +65,7 @@
   private final GitwebConfig gitwebConfig;
   private final DynamicItem<AvatarProvider> avatar;
   private final boolean enableSignedPush;
+  private final QueryDocumentationExecutor docSearcher;
 
   @Inject
   public GetServerInfo(
@@ -79,7 +81,8 @@
       @AnonymousCowardName String anonymousCowardName,
       GitwebConfig gitwebConfig,
       DynamicItem<AvatarProvider> avatar,
-      @EnableSignedPush boolean enableSignedPush) {
+      @EnableSignedPush boolean enableSignedPush,
+      QueryDocumentationExecutor docSearcher) {
     this.config = config;
     this.authConfig = authConfig;
     this.realm = realm;
@@ -93,6 +96,7 @@
     this.gitwebConfig = gitwebConfig;
     this.avatar = avatar;
     this.enableSignedPush = enableSignedPush;
+    this.docSearcher = docSearcher;
   }
 
   @Override
@@ -238,6 +242,7 @@
     info.reportBugUrl = cfg.getString("gerrit", null, "reportBugUrl");
     info.reportBugText = cfg.getString("gerrit", null, "reportBugText");
     info.docUrl = getDocUrl(cfg);
+    info.docSearch = docSearcher.isAvailable();
     info.editGpgKeys = toBoolean(enableSignedPush
         && cfg.getBoolean("gerrit", null, "editGpgKeys", true));
     return info;
@@ -366,10 +371,11 @@
   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 Boolean editGpgKeys;
   }
 
   public static class GitwebInfo {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java
index 382c8fd..653f5bd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java
@@ -58,15 +58,19 @@
     this.cacheMap = cacheMap;
   }
 
+  public Map<String, CacheInfo> getCacheInfos() {
+    Map<String, CacheInfo> cacheInfos = new TreeMap<>();
+    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+      cacheInfos.put(cacheNameOf(e.getPluginName(), e.getExportName()),
+          new CacheInfo(e.getProvider().get()));
+    }
+    return cacheInfos;
+  }
+
   @Override
   public Object apply(ConfigResource rsrc) {
     if (format == null) {
-      Map<String, CacheInfo> cacheInfos = new TreeMap<>();
-      for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-        cacheInfos.put(cacheNameOf(e.getPluginName(), e.getExportName()),
-            new CacheInfo(e.getProvider().get()));
-      }
-      return cacheInfos;
+      return getCacheInfos();
     } else {
       List<String> cacheNames = new ArrayList<>();
       for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
index 23615d6..34946ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
@@ -37,7 +37,7 @@
 public class ProjectOwnerGroupsProvider extends GroupSetProvider {
 
   public interface Factory {
-    public ProjectOwnerGroupsProvider create(Project.NameKey project);
+    ProjectOwnerGroupsProvider create(Project.NameKey project);
   }
 
   @AssistedInject
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
index 519a4a4..d6693ae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
@@ -48,13 +48,14 @@
       IOException, ConfigInvalidException {
     if (i.changesPerPage != null || i.showSiteHeader != null
         || i.useFlashClipboard != null || i.downloadScheme != null
-        || i.downloadCommand != null || i.copySelfOnEmail != null
+        || i.downloadCommand != null
         || i.dateFormat != null || i.timeFormat != null
         || i.relativeDateInChangeTable != null
         || i.sizeBarInChangeTable != null
         || i.legacycidInChangeTable != null
         || i.muteCommonPathPrefixes != null
-        || i.reviewCategoryStrategy != null) {
+        || i.reviewCategoryStrategy != null
+        || i.emailStrategy != null) {
       throw new BadRequestException("unsupported option");
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
new file mode 100644
index 0000000..c62583e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
@@ -0,0 +1,55 @@
+// 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.config;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class ThreadSettingsConfig {
+  private final int sshdThreads;
+  private final int httpdMaxThreads;
+  private final int sshdBatchThreads;
+  private final int databasePoolLimit;
+
+  @Inject
+  ThreadSettingsConfig(@GerritServerConfig Config cfg) {
+    int cores = Runtime.getRuntime().availableProcessors();
+    sshdThreads = cfg.getInt("sshd", "threads", 2 * cores);
+    httpdMaxThreads = cfg.getInt("httpd", "maxThreads", 25);
+    int defaultDatabasePoolLimit = sshdThreads + httpdMaxThreads + 2;
+    databasePoolLimit =
+        cfg.getInt("database", "poolLimit", defaultDatabasePoolLimit);
+    sshdBatchThreads = cores == 1 ? 1 : 2;
+  }
+
+  public int getDatabasePoolLimit() {
+    return databasePoolLimit;
+  }
+
+  public int getHttpdMaxThreads() {
+    return httpdMaxThreads;
+  }
+
+  public int getSshdThreads() {
+    return sshdThreads;
+  }
+
+  public int getSshdBatchTreads() {
+    return sshdBatchThreads;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index 446013e..438795f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -74,7 +74,7 @@
   }
 
   public List<DocResult> doQuery(String q) throws DocQueryException {
-    if (parser == null || searcher == null) {
+    if (!isAvailable()) {
       throw new DocQueryException("Documentation search not available");
     }
     try {
@@ -123,6 +123,10 @@
     return dir;
   }
 
+  public boolean isAvailable() {
+    return parser != null && searcher != null;
+  }
+
   @SuppressWarnings("serial")
   public static class DocQueryException extends Exception {
     DocQueryException() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java
new file mode 100644
index 0000000..eaaf1a83
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java
@@ -0,0 +1,43 @@
+// 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.events;
+
+import com.google.gerrit.common.EventListener;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class EventsMetrics implements EventListener {
+  private final Counter1<String> events;
+
+  @Inject
+  public EventsMetrics(MetricMaker metricMaker) {
+    events = metricMaker.newCounter(
+        "events",
+        new Description("Triggered events")
+          .setRate()
+          .setUnit("triggered events"),
+        Field.ofString("type"));
+  }
+
+  @Override
+  public void onEvent(com.google.gerrit.server.events.Event event) {
+    events.increment(event.getType());
+  }
+}
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 c54fe26..90d1783 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
@@ -77,10 +77,30 @@
  */
 public class BatchUpdate implements AutoCloseable {
   public interface Factory {
-    public BatchUpdate create(ReviewDb db, Project.NameKey project,
+    BatchUpdate create(ReviewDb db, Project.NameKey project,
         CurrentUser user, Timestamp when);
   }
 
+  /** Order of execution of the various phases. */
+  public static enum Order {
+    /**
+     * Update the repository and execute all ref updates before touching the
+     * database.
+     * <p>
+     * The default and most common, as Gerrit does not behave well when a patch
+     * set has no corresponding ref in the repo.
+     */
+    REPO_BEFORE_DB,
+
+    /**
+     * Update the database before touching the repository.
+     * <p>
+     * Generally only used when deleting patch sets, which should be deleted
+     * first from the database (for the same reason as above.)
+     */
+    DB_BEFORE_REPO;
+  }
+
   public class Context {
     public Project.NameKey getProject() {
       return project;
@@ -97,6 +117,10 @@
     public CurrentUser getUser() {
       return user;
     }
+
+    public Order getOrder() {
+      return order;
+    }
   }
 
   public class RepoContext extends Context {
@@ -135,6 +159,7 @@
   public class ChangeContext extends Context {
     private final ChangeControl ctl;
     private final ChangeUpdate update;
+    private boolean deleted;
 
     private ChangeContext(ChangeControl ctl) {
       this.ctl = ctl;
@@ -156,6 +181,10 @@
     public Change getChange() {
       return update.getChange();
     }
+
+    public void markDeleted() {
+      this.deleted = true;
+    }
   }
 
   public static class Op {
@@ -199,6 +228,7 @@
   private RevWalk revWalk;
   private BatchRefUpdate batchRefUpdate;
   private boolean closeRepo;
+  private Order order;
 
   @AssistedInject
   BatchUpdate(GitRepositoryManager repoManager,
@@ -221,6 +251,7 @@
     this.user = user;
     this.when = when;
     tz = serverIdent.getTimeZone();
+    order = Order.REPO_BEFORE_DB;
   }
 
   @Override
@@ -242,6 +273,11 @@
     return this;
   }
 
+  public BatchUpdate setOrder(Order order) {
+    this.order = order;
+    return this;
+  }
+
   private void initRepository() throws IOException {
     if (repo == null) {
       this.repo = repoManager.openRepository(project);
@@ -287,8 +323,19 @@
 
   public void execute() throws UpdateException, RestApiException {
     try {
-      executeRefUpdates();
-      executeChangeOps();
+      switch (order) {
+        case REPO_BEFORE_DB:
+          executeRefUpdates();
+          executeChangeOps();
+          break;
+        case DB_BEFORE_REPO:
+          executeChangeOps();
+          executeRefUpdates();
+          break;
+        default:
+          throw new IllegalStateException("invalid execution order: " + order);
+      }
+
       reindexChanges();
 
       if (batchRefUpdate != null) {
@@ -356,7 +403,11 @@
           db.rollback();
         }
         ctx.getChangeUpdate().commit();
-        indexFutures.add(indexer.indexAsync(id));
+        if (ctx.deleted) {
+          indexFutures.add(indexer.deleteAsync(id));
+        } else {
+          indexFutures.add(indexer.indexAsync(id));
+        }
       }
     } catch (Exception e) {
       Throwables.propagateIfPossible(e, RestApiException.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCache.java
index 391ccd0..209cbe0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCache.java
@@ -20,5 +20,5 @@
 import java.util.List;
 
 public interface ChangeCache {
-  public List<Change> get(Project.NameKey name);
+  List<Change> get(Project.NameKey name);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
index dee2df0..ab6d66c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
@@ -41,7 +41,7 @@
    *         repository.
    * @throws IOException the name cannot be read as a repository.
    */
-  public abstract Repository openRepository(Project.NameKey name)
+  Repository openRepository(Project.NameKey name)
       throws RepositoryNotFoundException, IOException;
 
   /**
@@ -58,7 +58,7 @@
    * @throws RepositoryNotFoundException the name is invalid.
    * @throws IOException the repository cannot be created.
    */
-  public abstract Repository createRepository(Project.NameKey name)
+  Repository createRepository(Project.NameKey name)
       throws RepositoryCaseMismatchException, RepositoryNotFoundException,
       IOException;
 
@@ -76,11 +76,11 @@
    *         repository.
    * @throws IOException the name cannot be read as a repository.
    */
-  public abstract Repository openMetadataRepository(Project.NameKey name)
+  Repository openMetadataRepository(Project.NameKey name)
       throws RepositoryNotFoundException, IOException;
 
   /** @return set of all known projects, sorted by natural NameKey order. */
-  public abstract SortedSet<Project.NameKey> list();
+  SortedSet<Project.NameKey> list();
 
   /**
    * Read the {@code GIT_DIR/description} file for gitweb.
@@ -94,7 +94,7 @@
    * @throws IOException the description file exists, but is not readable by
    *         this process.
    */
-  public abstract String getProjectDescription(Project.NameKey name)
+  String getProjectDescription(Project.NameKey name)
       throws RepositoryNotFoundException, IOException;
 
   /**
@@ -106,6 +106,6 @@
    * @param name the repository name, relative to the base directory.
    * @param description new description text for the repository.
    */
-  public abstract void setProjectDescription(Project.NameKey name,
+  void setProjectDescription(Project.NameKey name,
       final String description);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
index 0dc7aa9..097ab6e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
@@ -116,7 +116,7 @@
     LabelTypes labelTypes = ctl.getLabelTypes();
     for (PatchSetApproval psa : approvals) {
       Change.Id changeId = psa.getKey().getParentKey().getParentKey();
-      checkArgument(changeId.equals(ctl.getChange().getId()),
+      checkArgument(changeId.equals(ctl.getId()),
           "Approval %s does not match change %s",
           psa.getKey(), ctl.getChange().getKey());
       if (psa.isSubmit()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java
new file mode 100644
index 0000000..ebfaae7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java
@@ -0,0 +1,40 @@
+// 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.git;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.transport.PostReceiveHook;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceivePack;
+
+import java.util.Collection;
+
+class LazyPostReceiveHookChain implements PostReceiveHook {
+  private final DynamicSet<PostReceiveHook> hooks;
+
+  @Inject
+  LazyPostReceiveHookChain(DynamicSet<PostReceiveHook> hooks) {
+    this.hooks = hooks;
+  }
+
+  @Override
+  public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
+    for (PostReceiveHook h : hooks) {
+      h.onPostReceive(rp, commands);
+    }
+  }
+}
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 fc2f672..61689a1 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static org.eclipse.jgit.lib.RefDatabase.ALL;
 
 import com.google.common.base.Optional;
@@ -167,7 +168,6 @@
   private CodeReviewRevWalk rw;
   private RevFlag canMergeFlag;
   private ObjectInserter inserter;
-  private PersonIdent refLogIdent;
   private Map<Branch.NameKey, RefUpdate> pendingRefUpdates;
   private Map<Branch.NameKey, CodeReviewCommit> openBranches;
   private Map<Branch.NameKey, MergeTip> mergeTips;
@@ -427,8 +427,7 @@
       for (Project.NameKey project : br.keySet()) {
         openRepository(project);
         for (Branch.NameKey branch : br.get(project)) {
-
-          RefUpdate update = updateBranch(branch);
+          RefUpdate update = updateBranch(branch, caller);
           pendingRefUpdates.remove(branch);
 
           setDestProject(branch);
@@ -474,7 +473,6 @@
       toMerge.add(commit);
     }
     MergeTip mergeTip = strategy.run(branchTip, toMerge);
-    refLogIdent = strategy.getRefLogIdent();
     logDebug("Produced {} new commits", strategy.getNewCommits().size());
     commits.putAll(strategy.getNewCommits());
     return mergeTip;
@@ -720,8 +718,8 @@
     }
   }
 
-  private RefUpdate updateBranch(Branch.NameKey destBranch)
-      throws IntegrationException {
+  private RefUpdate updateBranch(Branch.NameKey destBranch,
+      IdentifiedUser caller) throws IntegrationException {
     RefUpdate branchUpdate = getPendingRefUpdate(destBranch);
     CodeReviewCommit branchTip = getBranchTip(destBranch);
 
@@ -755,7 +753,8 @@
       }
     }
 
-    branchUpdate.setRefLogIdent(refLogIdent);
+    branchUpdate.setRefLogIdent(
+        identifiedUserFactory.create(caller.getAccountId()).newRefLogIdent());
     branchUpdate.setForceUpdate(false);
     branchUpdate.setNewObjectId(currentTip);
     branchUpdate.setRefLogMessage("merged", true);
@@ -859,6 +858,9 @@
       // If mergeTip is null merge failed and mergeResultRev will not be read.
       ObjectId mergeResultRev =
           mergeTip != null ? mergeTip.getMergeResults().get(commit) : null;
+      // The change notes must be forcefully reloaded so that the SUBMIT
+      // approval that we added earlier is visible
+      commit.notes().reload();
       try {
         ChangeMessage msg;
         switch (s) {
@@ -1050,6 +1052,7 @@
 
     logDebug("Add approval for " + cd + " from user " + user);
     ChangeUpdate update = updateFactory.create(control, timestamp);
+    update.putReviewer(user.getAccountId(), REVIEWER);
     List<SubmitRecord> record = records.get(cd.change().getId());
     if (record != null) {
       update.merge(record);
@@ -1063,6 +1066,11 @@
       // If the submit strategy created a new revision (rebase, cherry-pick)
       // approve that as well
       if (!psIdNewRev.equals(psId)) {
+        update.setPatchSetId(psId);
+        update.commit();
+        // Create a new ChangeUpdate instance because we need to store meta data
+        // on another patch set (psIdNewRev).
+        update = updateFactory.create(control, timestamp);
         batch = approve(control, psIdNewRev, user,
             update, timestamp);
         // Write update commit after all normalized label commits.
@@ -1072,6 +1080,7 @@
     } finally {
       db.rollback();
     }
+    update.commit();
     indexer.index(db, cd.change());
   }
 
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 addbc1b..246647c 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
@@ -141,7 +141,7 @@
   private static final String KEY_CAN_OVERRIDE = "canOverride";
   private static final String KEY_Branch = "branch";
   private static final Set<String> LABEL_FUNCTIONS = ImmutableSet.of(
-      "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp");
+      "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp", "PatchSetLock");
 
   private static final String PLUGIN = "plugin";
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
index c7d925c..cd37c9e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
@@ -19,5 +19,5 @@
     INTERACTIVE, BATCH
   }
 
-  public WorkQueue.Executor getQueue(QueueType type);
+  WorkQueue.Executor getQueue(QueueType type);
 }
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 5da4ec7..f73db1c 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
@@ -53,7 +53,6 @@
 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.common.ChangeHookRunner.HookResult;
 import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
@@ -65,6 +64,7 @@
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -101,6 +101,7 @@
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -308,6 +309,7 @@
   private final SshInfo sshInfo;
   private final AllProjectsName allProjectsName;
   private final ReceiveConfig receiveConfig;
+  private final DynamicSet<ReceivePackInitializer> initializers;
   private final ChangeKindCache changeKindCache;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final SetHashtagsOp.Factory hashtagsFactory;
@@ -378,7 +380,10 @@
       final ChangeIndexer indexer,
       final SshInfo sshInfo,
       final AllProjectsName allProjectsName,
-      ReceiveConfig config,
+      ReceiveConfig receiveConfig,
+      TransferConfig transferConfig,
+      DynamicSet<ReceivePackInitializer> initializers,
+      Provider<LazyPostReceiveHookChain> lazyPostReceive,
       @Assisted final ProjectControl projectControl,
       @Assisted final Repository repo,
       final Provider<SubmoduleOp> subOpProvider,
@@ -420,7 +425,8 @@
     this.indexer = indexer;
     this.sshInfo = sshInfo;
     this.allProjectsName = allProjectsName;
-    this.receiveConfig = config;
+    this.receiveConfig = receiveConfig;
+    this.initializers = initializers;
     this.changeKindCache = changeKindCache;
     this.batchUpdateFactory = batchUpdateFactory;
     this.hashtagsFactory = hashtagsFactory;
@@ -448,6 +454,10 @@
     rp.setAllowCreates(true);
     rp.setAllowDeletes(true);
     rp.setAllowNonFastForwards(true);
+    rp.setRefLogIdent(user.newRefLogIdent());
+    rp.setTimeout(transferConfig.getTimeout());
+    rp.setMaxObjectSizeLimit(transferConfig.getEffectiveMaxObjectSizeLimit(
+        projectControl.getProjectState()));
     rp.setCheckReceivedObjects(ps.getConfig().getCheckReceivedObjects());
     rp.setRefFilter(new RefFilter() {
       @Override
@@ -465,7 +475,7 @@
     });
 
     if (!projectControl.allRefsAreVisible()) {
-      rp.setCheckReferencedObjectsAreReachable(config.checkReferencedObjectsAreReachable);
+      rp.setCheckReferencedObjectsAreReachable(receiveConfig.checkReferencedObjectsAreReachable);
       rp.setAdvertiseRefsHook(new VisibleRefFilter(tagCache, changeCache, repo, projectControl, db, false));
     }
     List<AdvertiseRefsHook> advHooks = new ArrayList<>(3);
@@ -497,6 +507,13 @@
         db, queryProvider, projectControl.getProject().getNameKey()));
     advHooks.add(new HackPushNegotiateHook());
     rp.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
+    rp.setPostReceiveHook(lazyPostReceive.get());
+  }
+
+  public void init() {
+    for (ReceivePackInitializer i : initializers) {
+      i.init(projectControl.getProject().getNameKey(), rp);
+    }
   }
 
   /** Add reviewers for new (or updated) changes. */
@@ -880,19 +897,6 @@
         continue;
       }
 
-      HookResult result = hooks.doRefUpdateHook(project, cmd.getRefName(),
-                              user.getAccount(), cmd.getOldId(),
-                              cmd.getNewId());
-
-      if (result != null) {
-        final String message = result.toString().trim();
-        if (result.getExitValue() != 0) {
-          reject(cmd, message);
-          continue;
-        }
-        rp.sendMessage(message);
-      }
-
       if (MagicBranch.isMagicBranch(cmd.getRefName())) {
         parseMagicBranch(cmd);
         continue;
@@ -1751,9 +1755,7 @@
               insertChange(threadLocalDb);
             }
           }
-          synchronized (newProgress) {
-            newProgress.update(1);
-          }
+          synchronizedIncrement(newProgress);
           return null;
         }
       }));
@@ -1792,6 +1794,16 @@
               ins.getChange().getId(),
               hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags))
                 .setRunHooks(false));
+          if (!Strings.isNullOrEmpty(magicBranch.topic)) {
+            bu.addOp(
+                ins.getChange().getId(),
+                new BatchUpdate.Op() {
+                  @Override
+                  public void updateChange(ChangeContext ctx) throws Exception {
+                    ctx.getChangeUpdate().setTopic(magicBranch.topic);
+                  }
+                });
+          }
         }
         bu.execute();
       }
@@ -1966,7 +1978,7 @@
       }
     }
 
-    boolean validate(boolean autoClose) throws IOException {
+    boolean validate(boolean autoClose) throws IOException, OrmException {
       if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
         return false;
       } else if (change == null) {
@@ -1989,8 +2001,12 @@
       }
 
       changeCtl = projectControl.controlFor(change);
-      if (!changeCtl.canAddPatchSet()) {
-        reject(inputCommand, "cannot replace " + ontoChange);
+      if (!changeCtl.canAddPatchSet(db)) {
+        String locked = ".";
+        if (changeCtl.isPatchSetLocked(db)) {
+          locked = ". Change is patch set locked.";
+        }
+        reject(inputCommand, "cannot replace " + ontoChange + locked);
         return false;
       } else if (change.getStatus().isClosed()) {
         reject(inputCommand, "change " + ontoChange + " closed");
@@ -2156,9 +2172,7 @@
               }
             }
           } finally {
-            synchronized (replaceProgress) {
-              replaceProgress.update(1);
-            }
+            synchronizedIncrement(replaceProgress);
           }
         }
       }));
@@ -2234,11 +2248,15 @@
         recipients.add(magicBranch.getMailRecipients());
         approvals = magicBranch.labels;
         Set<String> hashtags = magicBranch.hashtags;
+        ChangeNotes notes = changeCtl.getNotes().load();
         if (!hashtags.isEmpty()) {
-          ChangeNotes notes = changeCtl.getNotes().load();
           hashtags.addAll(notes.getHashtags());
           update.setHashtags(hashtags);
         }
+        if (magicBranch.topic != null
+            && !magicBranch.topic.equals(notes.getChange().getTopic())) {
+          update.setTopic(magicBranch.topic);
+        }
       }
       recipients.add(getRecipientsFromFooters(accountResolver, newPatchSet, footerLines));
       recipients.remove(me);
@@ -2824,4 +2842,10 @@
   private static boolean isConfig(final ReceiveCommand cmd) {
     return cmd.getRefName().equals(RefNames.REFS_CONFIG);
   }
+
+  private static void synchronizedIncrement(Task p) {
+    synchronized (p) {
+      p.update(1);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java
index ec0bdaf..ee229d4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java
@@ -32,5 +32,5 @@
    * @param project project for which the ReceivePack is created
    * @param receivePack the ReceivePack instance which is being initialized
    */
-  public void init(Project.NameKey project, ReceivePack receivePack);
+  void init(Project.NameKey project, ReceivePack receivePack);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
index 3cbac3b..d77c7e2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
@@ -28,7 +28,7 @@
 
 public class TabFile {
   public interface Parser {
-    public String parse(String str);
+    String parse(String str);
   }
 
   public static Parser TRIM = new Parser() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
new file mode 100644
index 0000000..fd4e7d3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
@@ -0,0 +1,98 @@
+// 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.git;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram1;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.storage.pack.PackStatistics;
+import org.eclipse.jgit.transport.PostUploadHook;
+
+@Singleton
+public class UploadPackMetricsHook implements PostUploadHook {
+  enum Operation {
+    CLONE,
+    FETCH;
+  }
+
+  private final Counter1<Operation> requestCount;
+  private final Timer1<Operation> counting;
+  private final Timer1<Operation> compressing;
+  private final Timer1<Operation> writing;
+  private final Histogram1<Operation> packBytes;
+
+  @Inject
+  UploadPackMetricsHook(MetricMaker metricMaker) {
+    Field<Operation> operation = Field.ofEnum(Operation.class, "operation");
+    requestCount = metricMaker.newCounter(
+        "git/upload-pack/request_count",
+        new Description("Total number of git-upload-pack requests")
+          .setRate()
+          .setUnit("requests"),
+        operation);
+
+    counting = metricMaker.newTimer(
+        "git/upload-pack/phase_counting",
+        new Description("Time spenting in the 'Counting...' phase")
+          .setCumulative()
+          .setUnit(Units.MILLISECONDS),
+        operation);
+
+    compressing = metricMaker.newTimer(
+        "git/upload-pack/phase_compressing",
+        new Description("Time spenting in the 'Compressing...' phase")
+          .setCumulative()
+          .setUnit(Units.MILLISECONDS),
+        operation);
+
+    writing = metricMaker.newTimer(
+        "git/upload-pack/phase_writing",
+        new Description("Time spenting transferring bytes to client")
+          .setCumulative()
+          .setUnit(Units.MILLISECONDS),
+        operation);
+
+    packBytes = metricMaker.newHistogram(
+        "git/upload-pack/pack_bytes",
+        new Description("Distribution of sizes of packs sent to clients")
+          .setCumulative()
+          .setUnit(Units.BYTES),
+        operation);
+  }
+
+  @Override
+  public void onPostUpload(PackStatistics stats) {
+    Operation op = Operation.FETCH;
+    if (stats.getUninterestingObjects() == null
+        || stats.getUninterestingObjects().isEmpty()) {
+      op = Operation.CLONE;
+    }
+
+    requestCount.increment(op);
+    counting.record(op, stats.getTimeCounting(), MILLISECONDS);
+    compressing.record(op, stats.getTimeCompressing(), MILLISECONDS);
+    writing.record(op, stats.getTimeWriting(), MILLISECONDS);
+    packBytes.record(op, stats.getTotalBytes());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
index 4a8163b..954e412 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
@@ -246,7 +246,7 @@
   /** Runnable needing to know it was canceled. */
   public interface CancelableRunnable extends Runnable {
     /** Notifies the runnable it was canceled. */
-    public void cancel();
+    void cancel();
   }
 
   /** A wrapper around a scheduled Runnable, as maintained in the queue. */
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 61b1b00..2714015 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
@@ -176,6 +176,7 @@
         // Merge conflict; don't update change.
         return;
       }
+      ctx.getChangeUpdate().setPatchSetId(psId);
       PatchSet ps = new PatchSet(psId);
       ps.setCreatedOn(ctx.getWhen());
       ps.setUploader(args.caller.getAccountId());
@@ -191,6 +192,7 @@
       for (PatchSetApproval a : args.approvalsUtil.byPatchSet(
           args.db, toMerge.getControl(), toMerge.getPatchsetId())) {
         approvals.add(new PatchSetApproval(ps.getId(), a));
+        ctx.getChangeUpdate().putApproval(a.getLabel(), a.getValue());
       }
       args.db.patchSetApprovals().insert(approvals);
 
@@ -200,7 +202,6 @@
           args.changeControlFactory.controlFor(toMerge.change(), args.caller));
       mergeTip.moveTipTo(newCommit, newCommit);
       newCommits.put(c.getId(), newCommit);
-      setRefLogIdent();
     }
   }
 
@@ -240,7 +241,6 @@
       }
       args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
           mergeTip.getCurrentTip(), args.alreadyAccepted);
-      setRefLogIdent();
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
index 1709659..67162ea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
@@ -44,17 +44,11 @@
 
     args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
         newMergeTipCommit, args.alreadyAccepted);
-    setRefLogIdent();
 
     return mergeTip;
   }
 
   @Override
-  public boolean retryOnLockFailure() {
-    return false;
-  }
-
-  @Override
   public boolean dryRun(CodeReviewCommit mergeTip,
       CodeReviewCommit toMerge) throws IntegrationException {
     return args.mergeUtil.canFastForward(args.mergeSorter, mergeTip, args.rw,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
index 9f5f521..e7ad79c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
@@ -55,7 +55,6 @@
 
     args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
         mergeTip.getCurrentTip(), args.alreadyAccepted);
-    setRefLogIdent();
 
     return mergeTip;
   }
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 4ebe461..6894b57 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
@@ -61,7 +61,6 @@
 
     args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag, branchTip,
         args.alreadyAccepted);
-    setRefLogIdent();
     return mergeTip;
   }
 
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 cd6b5bd..9e5fcd2 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
@@ -110,7 +110,6 @@
                 CommitMergeStatus.CLEAN_REBASE);
             newCommits.put(newPatchSet.getId().getParentKey(),
                 mergeTip.getCurrentTip());
-            setRefLogIdent();
           } catch (MergeConflictException e) {
             n.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
             throw new IntegrationException(
@@ -140,7 +139,6 @@
           }
           args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
               mergeTip.getCurrentTip(), args.alreadyAccepted);
-          setRefLogIdent();
         } catch (IOException e) {
           throw new IntegrationException("Cannot merge " + n.name(), e);
         }
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 5215f55..c4c7e88 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
@@ -35,7 +35,6 @@
 
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
@@ -108,8 +107,6 @@
 
   protected final Arguments args;
 
-  private PersonIdent refLogIdent;
-
   SubmitStrategy(Arguments args) {
     this.args = args;
   }
@@ -128,7 +125,6 @@
    */
   public final MergeTip run(final CodeReviewCommit currentTip,
       final Collection<CodeReviewCommit> toMerge) throws IntegrationException {
-    refLogIdent = null;
     checkState(args.caller != null);
     return _run(currentTip, toMerge);
   }
@@ -153,19 +149,6 @@
       CodeReviewCommit toMerge) throws IntegrationException;
 
   /**
-   * Returns the identity that should be used for reflog entries when updating
-   * the destination branch.
-   * <p>
-   * The reflog identity may only be set during {@link #run(CodeReviewCommit,
-   * Collection)}, and this method is invalid to call beforehand.
-   *
-   * @return the ref log identity, which may be {@code null}.
-   */
-  public final PersonIdent getRefLogIdent() {
-    return refLogIdent;
-  }
-
-  /**
    * Returns all commits that have been newly created for the changes that are
    * getting merged.
    * <p>
@@ -180,28 +163,4 @@
   public Map<Change.Id, CodeReviewCommit> getNewCommits() {
     return Collections.emptyMap();
   }
-
-  /**
-   * Returns whether a merge that failed with {@link Result#LOCK_FAILURE} should
-   * be retried.
-   * <p>
-   * May be overridden by subclasses.
-   *
-   * @return {@code true} if a merge that failed with
-   *         {@link Result#LOCK_FAILURE} should be retried, otherwise
-   *         {@code false}
-   */
-  public boolean retryOnLockFailure() {
-    return true;
-  }
-
-  /**
-   * Set the ref log identity if it wasn't set yet.
-   */
-  protected final void setRefLogIdent() {
-    if (refLogIdent == null) {
-      refLogIdent = args.identifiedUserFactory.create(
-          args.caller.getAccountId()).newRefLogIdent();
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
index 029096e..91c6a14 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
@@ -23,7 +23,8 @@
   private static final long serialVersionUID = 1L;
   private final List<CommitValidationMessage> messages;
 
-  public CommitValidationException(String reason, List<CommitValidationMessage> messages) {
+  public CommitValidationException(String reason,
+      List<CommitValidationMessage> messages) {
     super(reason);
     this.messages = messages;
   }
@@ -41,4 +42,16 @@
   public List<CommitValidationMessage> getMessages() {
     return messages;
   }
+
+  /** @return the reason string along with all validation messages. */
+  public String getFullMessage() {
+    StringBuilder sb = new StringBuilder(getMessage());
+    if (!messages.isEmpty()) {
+      sb.append(':');
+      for (CommitValidationMessage msg : messages) {
+        sb.append("\n  ").append(msg.getMessage());
+      }
+    }
+    return sb.toString();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
index e98c49b..9d7b3e6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
@@ -34,6 +34,6 @@
    * @return list of validation messages
    * @throws CommitValidationException if validation fails
    */
-  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+  List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
       throws CommitValidationException;
 }
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 6ac5707..b069ca9 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
@@ -131,6 +131,7 @@
     validators.add(new ConfigValidator(refControl, repo));
     validators.add(new BannedCommitsValidator(rejectCommits));
     validators.add(new PluginCommitValidationListener(commitValidationListeners));
+    validators.add(new ChangeHookValidator(hooks));
 
     List<CommitValidationMessage> messages = new LinkedList<>();
 
@@ -164,7 +165,7 @@
     }
     validators.add(new ConfigValidator(refControl, repo));
     validators.add(new PluginCommitValidationListener(commitValidationListeners));
-    validators.add(new ChangeHookValidator(refControl, hooks));
+    validators.add(new ChangeHookValidator(hooks));
 
     List<CommitValidationMessage> messages = new LinkedList<>();
 
@@ -548,42 +549,37 @@
   /** Reject commits that don't pass user-supplied ref-update hook. */
   public static class ChangeHookValidator implements
       CommitValidationListener {
-    private final RefControl refControl;
     private final ChangeHooks hooks;
 
-    public ChangeHookValidator(RefControl refControl, ChangeHooks hooks) {
-      this.refControl = refControl;
+    public ChangeHookValidator(ChangeHooks hooks) {
       this.hooks = hooks;
     }
 
     @Override
     public List<CommitValidationMessage> onCommitReceived(
         CommitReceivedEvent receiveEvent) throws CommitValidationException {
+      IdentifiedUser user = receiveEvent.user;
+      String refname = receiveEvent.refName;
+      ObjectId old = ObjectId.zeroId();
+      if (receiveEvent.commit.getParentCount() > 0) {
+        old = receiveEvent.commit.getParent(0);
+      }
 
-      if (refControl.getUser().isIdentifiedUser()) {
-        IdentifiedUser user = refControl.getUser().asIdentifiedUser();
-
-        String refname = receiveEvent.refName;
-        ObjectId old = ObjectId.zeroId();
-        if (receiveEvent.commit.getParentCount() > 0) {
-          old = receiveEvent.commit.getParent(0);
-        }
-        if (receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) {
-          /*
-           * If the ref-update hook tries to distinguish behavior between pushes to
-           * refs/heads/... and refs/for/..., make sure we send it the correct refname.
-           * Also, if this is targetting refs/for/, make sure we behave the same as
-           * what a push to refs/for/ would behave; in particular, setting oldrev to
-           * 0000000000000000000000000000000000000000.
-           */
-          refname = refname.replace(R_HEADS, "refs/for/refs/heads/");
-          old = ObjectId.zeroId();
-        }
-        HookResult result = hooks.doRefUpdateHook(receiveEvent.project, refname,
-            user.getAccount(), old, receiveEvent.commit);
-        if (result != null && result.getExitValue() != 0) {
-            throw new CommitValidationException(result.toString().trim());
-        }
+      if (receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) {
+        /*
+          * If the ref-update hook tries to distinguish behavior between pushes to
+          * refs/heads/... and refs/for/..., make sure we send it the correct refname.
+          * Also, if this is targetting refs/for/, make sure we behave the same as
+          * what a push to refs/for/ would behave; in particular, setting oldrev to
+          * 0000000000000000000000000000000000000000.
+          */
+        refname = refname.replace(R_HEADS, "refs/for/refs/heads/");
+        old = ObjectId.zeroId();
+      }
+      HookResult result = hooks.doRefUpdateHook(receiveEvent.project, refname,
+          user.getAccount(), old, receiveEvent.commit);
+      if (result != null && result.getExitValue() != 0) {
+          throw new CommitValidationException(result.toString().trim());
       }
       return Collections.emptyList();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
index 0a8d245..6e98223 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
@@ -39,7 +39,7 @@
    * @param patchSetId the patch set ID
    * @throws MergeValidationException if the commit fails to validate
    */
-  public void onPreMerge(Repository repo,
+  void onPreMerge(Repository repo,
       CodeReviewCommit commit,
       ProjectState destProject,
       Branch.NameKey destBranch,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
index 2c032c4..e6923c1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
@@ -51,7 +51,7 @@
    * @throws ValidationException to block the upload and send a message
    *         back to the end-user over the client's protocol connection.
    */
-  public void onPreUpload(Repository repository, Project project,
+  void onPreUpload(Repository repository, Project project,
       String remoteHost, UploadPack up, Collection<? extends ObjectId> wants,
       Collection<? extends ObjectId> haves)
       throws ValidationException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
index 405cdf1..d623b31 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.server.group;
 
-import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Ordering;
 import com.google.gerrit.common.data.GroupDetail;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -30,13 +28,13 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupDetailFactory;
+import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
 import org.kohsuke.args4j.Option;
 
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -83,15 +81,7 @@
     final Map<Account.Id, AccountInfo> members =
         getMembers(groupId, new HashSet<AccountGroup.UUID>());
     final List<AccountInfo> memberInfos = Lists.newArrayList(members.values());
-    Collections.sort(memberInfos, new Comparator<AccountInfo>() {
-      @Override
-      public int compare(AccountInfo a, AccountInfo b) {
-        return ComparisonChain.start()
-            .compare(a.name, b.name, Ordering.natural().nullsFirst())
-            .compare(a.email, b.email, Ordering.natural().nullsFirst())
-            .compare(a._accountId, b._accountId, Ordering.natural().nullsFirst()).result();
-      }
-    });
+    Collections.sort(memberInfos, AccountInfoComparator.ORDER_NULLS_FIRST);
     return memberInfos;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndex.java
index 02e737a..258f1e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndex.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndex.java
@@ -35,10 +35,10 @@
  */
 public interface ChangeIndex {
   /** @return the schema version used by this index. */
-  public Schema<ChangeData> getSchema();
+  Schema<ChangeData> getSchema();
 
   /** Close this index. */
-  public void close();
+  void close();
 
   /**
    * Update a change document in the index.
@@ -52,7 +52,7 @@
    *
    * @throws IOException
    */
-  public void replace(ChangeData cd) throws IOException;
+  void replace(ChangeData cd) throws IOException;
 
   /**
    * Delete a change document from the index by id.
@@ -61,14 +61,14 @@
    *
    * @throws IOException
    */
-  public void delete(Change.Id id) throws IOException;
+  void delete(Change.Id id) throws IOException;
 
   /**
    * Delete all change documents from the index.
    *
    * @throws IOException
    */
-  public void deleteAll() throws IOException;
+  void deleteAll() throws IOException;
 
   /**
    * Convert the given operator predicate into a source searching the index and
@@ -90,7 +90,7 @@
    * @throws QueryParseException if the predicate could not be converted to an
    *     indexed data source.
    */
-  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
+  ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
       throws QueryParseException;
 
   /**
@@ -99,5 +99,5 @@
    * @param ready whether the index is ready
    * @throws IOException
    */
-  public void markReady(boolean ready) throws IOException;
+  void markReady(boolean ready) throws IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexer.java
index 6c484d8..84edcbf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeIndexer.java
@@ -239,9 +239,7 @@
         try {
           ChangeData cd = changeDataFactory.create(
               newCtx.getReviewDbProvider().get(), id);
-          for (ChangeIndex i : getWriteIndexes()) {
-            i.replace(cd);
-          }
+          index(cd);
           return null;
         } finally  {
           context.setContext(oldCtx);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedChangeQuery.java
index 683f8cf..28fa9f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedChangeQuery.java
@@ -50,7 +50,7 @@
     int backendLimit = opts.config().maxLimit();
     int limit = Ints.saturatedCast((long) opts.limit() + opts.start());
     limit = Math.min(limit, backendLimit);
-    return QueryOptions.create(opts.config(), 0, limit);
+    return QueryOptions.create(opts.config(), 0, limit, opts.fields());
   }
 
   private final ChangeIndex index;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexAfterUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexAfterUpdate.java
index 31fbb40..9d573e1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexAfterUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexAfterUpdate.java
@@ -69,6 +69,11 @@
 
   @Override
   public void onGitReferenceUpdated(final Event event) {
+    if (event.getRefName().startsWith(RefNames.REFS_CHANGES)
+        || event.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
+        || event.getRefName().startsWith(RefNames.REFS_USERS)) {
+      return;
+    }
     Futures.transformAsync(
         executor.submit(new GetChanges(event)),
         new AsyncFunction<List<Change>, List<Void>>() {
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 df70292..a605461 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
@@ -61,6 +61,8 @@
   }
 
   private final ImmutableMap<String, FieldDef<T, ?>> fields;
+  private final ImmutableMap<String, FieldDef<T, ?>> storedFields;
+
   private int version;
 
   protected Schema(Iterable<FieldDef<T, ?>> fields) {
@@ -71,10 +73,15 @@
   public Schema(int version, Iterable<FieldDef<T, ?>> fields) {
     this.version = version;
     ImmutableMap.Builder<String, FieldDef<T, ?>> b = ImmutableMap.builder();
+    ImmutableMap.Builder<String, FieldDef<T, ?>> sb = ImmutableMap.builder();
     for (FieldDef<T, ?> f : fields) {
       b.put(f.getName(), f);
+      if (f.isStored()) {
+        sb.put(f.getName(), f);
+      }
     }
     this.fields = b.build();
+    this.storedFields = sb.build();
   }
 
   public final int getVersion() {
@@ -95,6 +102,14 @@
   }
 
   /**
+   * @return all fields in this schema where {@link FieldDef#isStored()} is
+   *     true.
+   */
+  public final ImmutableMap<String, FieldDef<T, ?>> getStoredFields() {
+    return storedFields;
+  }
+
+  /**
    * Look up fields in this schema.
    *
    * @param first the preferred field to look up.
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/AddKeySender.java
index 0f1e86e..f825d1c 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/AddKeySender.java
@@ -25,9 +25,9 @@
 
 public class AddKeySender extends OutgoingEmail {
   public interface Factory {
-    public AddKeySender create(IdentifiedUser user, AccountSshKey sshKey);
+    AddKeySender create(IdentifiedUser user, AccountSshKey sshKey);
 
-    public AddKeySender create(IdentifiedUser user, List<String> gpgKey);
+    AddKeySender create(IdentifiedUser user, List<String> gpgKey);
   }
 
   private final IdentifiedUser callingUser;
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/ChangeEmail.java
index 8948ce3..e8101de 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/ChangeEmail.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.mail;
 
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -23,10 +25,8 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.StarredChange;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.mail.ProjectWatch.Watchers;
-import com.google.gerrit.server.notedb.ReviewerState;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -296,9 +296,9 @@
     try {
       // BCC anyone who has starred this change.
       //
-      for (StarredChange w : args.db.get().starredChanges().byChange(
-          change.getId())) {
-        super.add(RecipientType.BCC, w.getAccountId());
+      for (Account.Id accountId : args.starredChangesUtil
+          .byChange(change.getId())) {
+        super.add(RecipientType.BCC, accountId);
       }
     } catch (OrmException err) {
       // Just don't BCC everyone. Better to send a partial message to those
@@ -329,7 +329,7 @@
   /** Users who have non-zero approval codes on the change. */
   protected void ccExistingReviewers() {
     try {
-      for (Account.Id id : changeData.reviewers().get(ReviewerState.REVIEWER)) {
+      for (Account.Id id : changeData.reviewers().get(REVIEWER)) {
         add(RecipientType.CC, id);
       }
     } catch (OrmException err) {
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
index 3d0041c..3e5e167 100644
--- 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
@@ -52,7 +52,7 @@
       .getLogger(CommentSender.class);
 
   public static interface Factory {
-    public CommentSender create(NotifyHandling notify, Change.Id id);
+    CommentSender create(NotifyHandling notify, Change.Id id);
   }
 
   private final NotifyHandling notify;
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/CreateChangeSender.java
index 29895d9..e67b8ce 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/CreateChangeSender.java
@@ -33,7 +33,7 @@
       LoggerFactory.getLogger(CreateChangeSender.class);
 
   public static interface Factory {
-    public CreateChangeSender create(Change.Id id);
+    CreateChangeSender create(Change.Id id);
   }
 
   @Inject
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/EmailArguments.java
index 8e5fa6f..88c9199 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/EmailArguments.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupBackend;
@@ -73,6 +74,7 @@
   final RuntimeInstance velocityRuntime;
   final EmailSettings settings;
   final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
+  final StarredChangesUtil starredChangesUtil;
 
   @Inject
   EmailArguments(GitRepositoryManager server, ProjectCache projectCache,
@@ -96,7 +98,8 @@
       RuntimeInstance velocityRuntime,
       EmailSettings settings,
       @SshAdvertisedAddresses List<String> sshAddresses,
-      DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners) {
+      DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners,
+      StarredChangesUtil starredChangesUtil) {
     this.server = server;
     this.projectCache = projectCache;
     this.groupBackend = groupBackend;
@@ -122,5 +125,6 @@
     this.settings = settings;
     this.sshAddresses = sshAddresses;
     this.outgoingEmailValidationListeners = outgoingEmailValidationListeners;
+    this.starredChangesUtil = starredChangesUtil;
   }
 }
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 58bdac1..b6fb006 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
@@ -28,7 +28,7 @@
    *         the string provides proof the user has the ability to read messages
    *         sent to that address. Must not be null.
    */
-  public String encode(Account.Id accountId, String emailAddress);
+  String encode(Account.Id accountId, String emailAddress);
 
   /**
    * Decode a token previously created.
@@ -36,7 +36,7 @@
    * @return a pair of account id and email address.
    * @throws InvalidTokenException the token is invalid, expired, malformed, etc.
    */
-  public ParsedToken decode(String tokenString) throws InvalidTokenException;
+  ParsedToken decode(String tokenString) throws InvalidTokenException;
 
   /** Exception thrown when a token does not parse correctly. */
   public static class InvalidTokenException extends Exception {
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/FromAddressGenerator.java
index dec3d2c..9bcabc3 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/FromAddressGenerator.java
@@ -18,7 +18,7 @@
 
 /** Constructs an address to send email from. */
 public interface FromAddressGenerator {
-  public boolean isGenericAddress(Account.Id fromId);
+  boolean isGenericAddress(Account.Id fromId);
 
-  public Address from(Account.Id fromId);
+  Address from(Account.Id fromId);
 }
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 c6e59eb..a3a048a 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
@@ -14,13 +14,16 @@
 
 package com.google.gerrit.server.mail;
 
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+
 import com.google.common.collect.Multimap;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.errors.NoSuchAccountException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.notedb.ReviewerState;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gwtorm.server.OrmException;
 
 import org.eclipse.jgit.revwalk.FooterKey;
@@ -56,10 +59,10 @@
   }
 
   public static MailRecipients getRecipientsFromReviewers(
-      Multimap<ReviewerState, Account.Id> reviewers) {
+      Multimap<ReviewerStateInternal, Account.Id> reviewers) {
     MailRecipients recipients = new MailRecipients();
-    recipients.reviewers.addAll(reviewers.get(ReviewerState.REVIEWER));
-    recipients.cc.addAll(reviewers.get(ReviewerState.CC));
+    recipients.reviewers.addAll(reviewers.get(REVIEWER));
+    recipients.cc.addAll(reviewers.get(CC));
     return recipients;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergeFailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergeFailSender.java
index ba75723..f91b0fd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergeFailSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergeFailSender.java
@@ -23,7 +23,7 @@
 /** Send notice about a change failing to merged. */
 public class MergeFailSender extends ReplyToChangeSender {
   public static interface Factory {
-    public MergeFailSender create(Change.Id id);
+    MergeFailSender create(Change.Id id);
   }
 
   @Inject
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/MergedSender.java
index 7fbcf8d..9725e7f 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/MergedSender.java
@@ -31,7 +31,7 @@
 /** Send notice about a change successfully merged. */
 public class MergedSender extends ReplyToChangeSender {
   public static interface Factory {
-    public MergedSender create(Change.Id id);
+    MergedSender create(Change.Id id);
   }
 
   private final LabelTypes labelTypes;
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/OutgoingEmail.java
index 7dd51fe..5222544 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/OutgoingEmail.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.EmailStrategy;
 import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.mail.EmailHeader.AddressList;
@@ -95,28 +96,29 @@
     if (shouldSendMessage()) {
       if (fromId != null) {
         final Account fromUser = args.accountCache.get(fromId).getAccount();
+        EmailStrategy strategy =
+            fromUser.getGeneralPreferences().getEmailStrategy();
 
-        if (fromUser.getGeneralPreferences().isCopySelfOnEmails()) {
+        if (strategy == EmailStrategy.CC_ON_OWN_COMMENTS) {
           // If we are impersonating a user, make sure they receive a CC of
           // this message so they can always review and audit what we sent
           // on their behalf to others.
           //
           add(RecipientType.CC, fromId);
-
         } else if (rcptTo.remove(fromId)) {
           // If they don't want a copy, but we queued one up anyway,
           // drop them from the recipient lists.
           //
-          final String fromEmail = fromUser.getPreferredEmail();
-          for (Iterator<Address> i = smtpRcptTo.iterator(); i.hasNext();) {
-            if (i.next().email.equals(fromEmail)) {
-              i.remove();
-            }
-          }
-          for (EmailHeader hdr : headers.values()) {
-            if (hdr instanceof AddressList) {
-              ((AddressList) hdr).remove(fromEmail);
-            }
+          removeUser(fromUser);
+        }
+
+        // Check the preferences of all recipients. If any user has disabled
+        // his email notifications then drop him from recipients' list
+        for (Account.Id id : rcptTo) {
+          Account thisUser = args.accountCache.get(id).getAccount();
+          if (thisUser.getGeneralPreferences().getEmailStrategy()
+                  == EmailStrategy.DISABLED) {
+            removeUser(thisUser);
           }
 
           if (smtpRcptTo.isEmpty()) {
@@ -476,6 +478,20 @@
     return r.toString();
   }
 
+  private void removeUser(Account user) {
+    String fromEmail = user.getPreferredEmail();
+    for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext();) {
+      if (j.next().email.equals(fromEmail)) {
+        j.remove();
+      }
+    }
+    for (EmailHeader hdr : headers.values()) {
+      if (hdr instanceof AddressList) {
+        ((AddressList) hdr).remove(fromEmail);
+      }
+    }
+  }
+
   private static String safeToString(Object obj) {
     return obj != null ? obj.toString() : "";
   }
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/RegisterNewEmailSender.java
index c4d374f..c5d56b8 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/RegisterNewEmailSender.java
@@ -23,7 +23,7 @@
 
 public class RegisterNewEmailSender extends OutgoingEmail {
   public interface Factory {
-    public RegisterNewEmailSender create(String address);
+    RegisterNewEmailSender create(String address);
   }
 
   private final EmailTokenVerifier tokenVerifier;
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/ReplacePatchSetSender.java
index 05c7933..7980845 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/ReplacePatchSetSender.java
@@ -31,7 +31,7 @@
 /** Send notice of new patch sets for reviewers. */
 public class ReplacePatchSetSender extends ReplyToChangeSender {
   public static interface Factory {
-    public ReplacePatchSetSender create(Change.Id id);
+    ReplacePatchSetSender create(Change.Id id);
   }
 
   private final Set<Account.Id> reviewers = new HashSet<>();
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/ReplyToChangeSender.java
index 62a6c72..fbfbe61 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/ReplyToChangeSender.java
@@ -22,7 +22,7 @@
 /** Alert a user to a reply to a change, usually commentary made during review. */
 public abstract class ReplyToChangeSender extends ChangeEmail {
   public static interface Factory<T extends ReplyToChangeSender> {
-    public T create(Change.Id id);
+    T create(Change.Id id);
   }
 
   protected ReplyToChangeSender(EmailArguments ea, String mc, ChangeData cd)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
index 43d53f0..96486e96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
@@ -31,7 +31,7 @@
    *         or cannot be determined, {@link MimeUtil2#UNKNOWN_MIME_TYPE} which
    *         is an alias for {@code application/octet-stream}.
    */
-  public abstract MimeType getMimeType(final String path, final byte[] content);
+  MimeType getMimeType(final String path, final byte[] content);
 
   /**
    * Is this content type safe to transmit to a browser directly?
@@ -42,6 +42,6 @@
    *         content type and wants it to be protected (typically by wrapping
    *         the data in a ZIP archive).
    */
-  public abstract boolean isSafeInline(final MimeType type);
+  boolean isSafeInline(final MimeType type);
 
 }
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 b1ca9e7..d4555b4 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
@@ -63,6 +63,11 @@
     return self();
   }
 
+  public T reload() throws OrmException {
+    loaded = false;
+    return load();
+  }
+
   public ObjectId loadRevision() throws OrmException {
     if (loaded) {
       return getRevision();
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 fb41027..22c8da4 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
@@ -86,8 +86,7 @@
   }
 
   public void setPatchSetId(PatchSet.Id psId) {
-    checkArgument(psId == null
-        || psId.getParentKey().equals(getChange().getId()));
+    checkArgument(psId == null || psId.getParentKey().equals(ctl.getId()));
     this.psId = psId;
   }
 
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 bd8f797..acd2852 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
@@ -98,7 +98,7 @@
     IdentifiedUser user = ctl.getUser().asIdentifiedUser();
     this.accountId = user.getAccountId();
     this.changeNotes = getChangeNotes().load();
-    this.draftNotes = draftNotesFactory.create(ctl.getChange().getId(),
+    this.draftNotes = draftNotesFactory.create(ctl.getId(),
         user.getAccountId());
 
     this.upsertComments = Lists.newArrayList();
@@ -273,7 +273,7 @@
 
   @Override
   protected String getRefName() {
-    return RefNames.refsDraftComments(accountId, getChange().getId());
+    return RefNames.refsDraftComments(accountId, ctl.getId());
   }
 
   @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 c561a0d..aeb8f46 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
@@ -33,6 +33,7 @@
   static final FooterKey FOOTER_STATUS = new FooterKey("Status");
   static final FooterKey FOOTER_SUBMITTED_WITH =
       new FooterKey("Submitted-with");
+  static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
 
   public static String changeRefName(Change.Id id) {
     StringBuilder r = new StringBuilder();
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 063ff5a..b5019e6 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
@@ -18,6 +18,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
@@ -116,10 +117,11 @@
 
   private final Change change;
   private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
-  private ImmutableSetMultimap<ReviewerState, Account.Id> reviewers;
+  private ImmutableSetMultimap<ReviewerStateInternal, Account.Id> reviewers;
   private ImmutableList<Account.Id> allPastReviewers;
   private ImmutableList<SubmitRecord> submitRecords;
-  private ImmutableListMultimap<PatchSet.Id, ChangeMessage> changeMessages;
+  private ImmutableList<ChangeMessage> allChangeMessages;
+  private ImmutableListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet;
   private ImmutableListMultimap<RevId, PatchLineComment> comments;
   private ImmutableSet<String> hashtags;
   NoteMap noteMap;
@@ -143,7 +145,7 @@
     return approvals;
   }
 
-  public ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers() {
+  public ImmutableSetMultimap<ReviewerStateInternal, Account.Id> getReviewers() {
     return reviewers;
   }
 
@@ -170,9 +172,18 @@
     return submitRecords;
   }
 
-  /** @return change messages by patch set, in chronological order. */
-  public ImmutableListMultimap<PatchSet.Id, ChangeMessage> getChangeMessages() {
-    return changeMessages;
+  /** @return all change messages, in chronological order, oldest first. */
+  public ImmutableList<ChangeMessage> getChangeMessages() {
+    return allChangeMessages;
+  }
+
+  /**
+   * @return change messages by patch set, in chronological order, oldest
+   *     first.
+   */
+  public ImmutableListMultimap<PatchSet.Id, ChangeMessage>
+      getChangeMessagesByPatchSet() {
+    return changeMessagesByPatchSet;
   }
 
   /** @return inline comments on each revision. */
@@ -250,18 +261,20 @@
         change.setStatus(parser.status);
       }
       approvals = parser.buildApprovals();
-      changeMessages = parser.buildMessages();
+      changeMessagesByPatchSet = parser.buildMessagesByPatchSet();
+      allChangeMessages = parser.buildAllMessages();
       comments = ImmutableListMultimap.copyOf(parser.comments);
       noteMap = parser.commentNoteMap;
+      change.setTopic(Strings.emptyToNull(parser.topic));
 
       if (parser.hashtags != null) {
         hashtags = ImmutableSet.copyOf(parser.hashtags);
       } else {
         hashtags = ImmutableSet.of();
       }
-      ImmutableSetMultimap.Builder<ReviewerState, Account.Id> reviewers =
+      ImmutableSetMultimap.Builder<ReviewerStateInternal, Account.Id> reviewers =
           ImmutableSetMultimap.builder();
-      for (Map.Entry<Account.Id, ReviewerState> e
+      for (Map.Entry<Account.Id, ReviewerStateInternal> e
           : parser.reviewers.entrySet()) {
         reviewers.put(e.getValue(), e.getKey());
       }
@@ -277,7 +290,8 @@
     approvals = ImmutableListMultimap.of();
     reviewers = ImmutableSetMultimap.of();
     submitRecords = ImmutableList.of();
-    changeMessages = ImmutableListMultimap.of();
+    allChangeMessages = ImmutableList.of();
+    changeMessagesByPatchSet = ImmutableListMultimap.of();
     comments = ImmutableListMultimap.of();
     hashtags = ImmutableSet.of();
   }
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 6f8cb2b..9d759ed 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
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST;
 
 import com.google.common.base.Enums;
@@ -26,6 +27,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Supplier;
 import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.LinkedListMultimap;
@@ -70,12 +72,13 @@
 import java.util.Set;
 
 class ChangeNotesParser implements AutoCloseable {
-  final Map<Account.Id, ReviewerState> reviewers;
+  final Map<Account.Id, ReviewerStateInternal> reviewers;
   final List<Account.Id> allPastReviewers;
   final List<SubmitRecord> submitRecords;
   final Multimap<RevId, PatchLineComment> comments;
   NoteMap commentNoteMap;
   Change.Status status;
+  String topic;
   Set<String> hashtags;
 
   private final Change.Id changeId;
@@ -84,7 +87,8 @@
   private final Repository repo;
   private final Map<PatchSet.Id,
       Table<Account.Id, String, Optional<PatchSetApproval>>> approvals;
-  private final Multimap<PatchSet.Id, ChangeMessage> changeMessages;
+  private final List<ChangeMessage> allChangeMessages;
+  private final Multimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet;
 
   ChangeNotesParser(Change change, ObjectId tip, RevWalk walk,
       GitRepositoryManager repoManager) throws RepositoryNotFoundException,
@@ -98,7 +102,8 @@
     reviewers = Maps.newLinkedHashMap();
     allPastReviewers = Lists.newArrayList();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
-    changeMessages = LinkedListMultimap.create();
+    allChangeMessages = Lists.newArrayList();
+    changeMessagesByPatchSet = LinkedListMultimap.create();
     comments = ArrayListMultimap.create();
   }
 
@@ -133,11 +138,16 @@
     return ImmutableListMultimap.copyOf(result);
   }
 
-  ImmutableListMultimap<PatchSet.Id, ChangeMessage> buildMessages() {
-    for (Collection<ChangeMessage> v : changeMessages.asMap().values()) {
-      Collections.sort((List<ChangeMessage>) v, ChangeNotes.MESSAGE_BY_TIME);
+  ImmutableList<ChangeMessage> buildAllMessages() {
+    return ImmutableList.copyOf(Lists.reverse(allChangeMessages));
+  }
+
+  ImmutableListMultimap<PatchSet.Id, ChangeMessage> buildMessagesByPatchSet() {
+    for (Collection<ChangeMessage> v :
+        changeMessagesByPatchSet.asMap().values()) {
+      Collections.reverse((List<ChangeMessage>) v);
     }
-    return ImmutableListMultimap.copyOf(changeMessages);
+    return ImmutableListMultimap.copyOf(changeMessagesByPatchSet);
   }
 
   private void parse(RevCommit commit) throws ConfigInvalidException {
@@ -147,6 +157,9 @@
     PatchSet.Id psId = parsePatchSetId(commit);
     Account.Id accountId = parseIdent(commit);
     parseChangeMessage(psId, accountId, commit);
+    if (topic == null) {
+      topic = parseTopic(commit);
+    }
     parseHashtags(commit);
 
 
@@ -160,13 +173,25 @@
       parseApproval(psId, accountId, commit, line);
     }
 
-    for (ReviewerState state : ReviewerState.values()) {
+    for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
       for (String line : commit.getFooterLines(state.getFooterKey())) {
         parseReviewer(state, line);
       }
     }
   }
 
+  private String parseTopic(RevCommit commit)
+      throws ConfigInvalidException {
+    List<String> topicLines = commit.getFooterLines(FOOTER_TOPIC);
+    if (topicLines.isEmpty()) {
+      return null;
+    } else if (topicLines.size() > 1) {
+      throw expectedOneFooter(FOOTER_TOPIC, topicLines);
+    }
+    return topicLines.get(0);
+  }
+
+
   private void parseHashtags(RevCommit commit) throws ConfigInvalidException {
     // Commits are parsed in reverse order and only the last set of hashtags should be used.
     if (hashtags != null) {
@@ -267,7 +292,8 @@
         new Timestamp(commit.getCommitterIdent().getWhen().getTime()),
         psId);
     changeMessage.setMessage(changeMsgString);
-    changeMessages.put(psId, changeMessage);
+    changeMessagesByPatchSet.put(psId, changeMessage);
+    allChangeMessages.add(changeMessage);
   }
 
   private void parseComments()
@@ -385,7 +411,7 @@
       GERRIT_PLACEHOLDER_HOST, email);
   }
 
-  private void parseReviewer(ReviewerState state, String line)
+  private void parseReviewer(ReviewerStateInternal state, String line)
       throws ConfigInvalidException {
     PersonIdent ident = RawParseUtils.parsePersonIdent(line);
     if (ident == null) {
@@ -398,11 +424,11 @@
   }
 
   private void pruneReviewers() {
-    Iterator<Map.Entry<Account.Id, ReviewerState>> rit =
+    Iterator<Map.Entry<Account.Id, ReviewerStateInternal>> rit =
         reviewers.entrySet().iterator();
     while (rit.hasNext()) {
-      Map.Entry<Account.Id, ReviewerState> e = rit.next();
-      if (e.getValue() == ReviewerState.REMOVED) {
+      Map.Entry<Account.Id, ReviewerStateInternal> e = rit.next();
+      if (e.getValue() == ReviewerStateInternal.REMOVED) {
         rit.remove();
         for (Table<Account.Id, ?, ?> curr : approvals.values()) {
           curr.rowKeySet().remove(e.getKey());
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/ChangeRebuilder.java
index d715947..22ccac7 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/ChangeRebuilder.java
@@ -30,6 +30,8 @@
 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.client.StarredChange;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchLineCommentsUtil;
@@ -42,9 +44,12 @@
 import com.google.inject.Provider;
 
 import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -95,7 +100,7 @@
   }
 
   public void rebuild(Change change, BatchRefUpdate bru,
-      BatchRefUpdate bruForDrafts, Repository changeRepo,
+      BatchRefUpdate bruAllUsers, Repository changeRepo,
       Repository allUsersRepo) throws NoSuchChangeException, IOException,
       OrmException {
     deleteRef(change, changeRepo);
@@ -169,15 +174,36 @@
           draftUpdate = draftUpdateFactory.create(
               controlFactory.controlFor(change, user), e.when);
           draftUpdate.setPatchSetId(e.psId);
-          batchForDrafts = draftUpdate.openUpdateInBatch(bruForDrafts);
+          batchForDrafts = draftUpdate.openUpdateInBatch(bruAllUsers);
         }
         e.applyDraft(draftUpdate);
       }
       writeToBatch(batchForDrafts, draftUpdate, allUsersRepo);
-      synchronized(bruForDrafts) {
+      synchronized(bruAllUsers) {
         batchForDrafts.commit();
       }
     }
+
+    createStarredChangesRefs(changeId, bruAllUsers, allUsersRepo);
+  }
+
+  private void createStarredChangesRefs(Change.Id changeId,
+      BatchRefUpdate bruAllUsers, Repository allUsersRepo)
+          throws IOException, OrmException {
+    ObjectId emptyTree = emptyTree(allUsersRepo);
+    for (StarredChange starred : dbProvider.get().starredChanges()
+        .byChange(changeId)) {
+      bruAllUsers.addCommand(new ReceiveCommand(ObjectId.zeroId(), emptyTree,
+          RefNames.refsStarredChanges(starred.getAccountId(), changeId)));
+    }
+  }
+
+  private static ObjectId emptyTree(Repository repo) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId id = oi.insert(Constants.OBJ_TREE, new byte[] {});
+      oi.flush();
+      return id;
+    }
   }
 
   private void deleteRef(Change change, Repository changeRepo)
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 cb35619..7c011b6 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
@@ -20,11 +20,13 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
 import static com.google.gerrit.server.notedb.CommentsInNotesUtil.addCommentToMap;
 
 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.Lists;
 import com.google.common.collect.Maps;
@@ -86,12 +88,13 @@
 
   private final AccountCache accountCache;
   private final Map<String, Optional<Short>> approvals;
-  private final Map<Account.Id, ReviewerState> reviewers;
+  private final Map<Account.Id, ReviewerStateInternal> reviewers;
   private Change.Status status;
   private String subject;
   private List<SubmitRecord> submitRecords;
   private final CommentsInNotesUtil commentsUtil;
   private List<PatchLineComment> comments;
+  private String topic;
   private Set<String> hashtags;
   private String changeMessage;
   private ChangeNotes notes;
@@ -165,7 +168,11 @@
 
   public void setStatus(Change.Status status) {
     checkArgument(status != Change.Status.MERGED,
-        "use submit(Iterable<PatchSetApproval>)");
+        "use merge(Iterable<SubmitRecord>)");
+    this.status = status;
+  }
+
+  public void fixStatus(Change.Status status) {
     this.status = status;
   }
 
@@ -312,17 +319,21 @@
 
   }
 
+  public void setTopic(String topic) {
+    this.topic = Strings.nullToEmpty(topic);
+  }
+
   public void setHashtags(Set<String> hashtags) {
     this.hashtags = hashtags;
   }
 
-  public void putReviewer(Account.Id reviewer, ReviewerState type) {
-    checkArgument(type != ReviewerState.REMOVED, "invalid ReviewerType");
+  public void putReviewer(Account.Id reviewer, ReviewerStateInternal type) {
+    checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType");
     reviewers.put(reviewer, type);
   }
 
   public void removeReviewer(Account.Id reviewer) {
-    reviewers.put(reviewer, ReviewerState.REMOVED);
+    reviewers.put(reviewer, ReviewerStateInternal.REMOVED);
   }
 
   /** @return the tree id for the updated tree */
@@ -383,7 +394,7 @@
 
   @Override
   protected String getRefName() {
-    return ChangeNoteUtil.changeRefName(getChange().getId());
+    return ChangeNoteUtil.changeRefName(ctl.getId());
   }
 
   @Override
@@ -414,11 +425,15 @@
       addFooter(msg, FOOTER_STATUS, status.name().toLowerCase());
     }
 
+    if (topic != null) {
+      addFooter(msg, FOOTER_TOPIC, topic);
+    }
+
     if (hashtags != null) {
       addFooter(msg, FOOTER_HASHTAGS, Joiner.on(",").join(hashtags));
     }
 
-    for (Map.Entry<Account.Id, ReviewerState> e : reviewers.entrySet()) {
+    for (Map.Entry<Account.Id, ReviewerStateInternal> e : reviewers.entrySet()) {
       Account account = accountCache.get(e.getKey()).getAccount();
       PersonIdent ident = newIdent(account, when);
       addFooter(msg, e.getValue().getFooterKey())
@@ -477,7 +492,8 @@
         && status == null
         && subject == null
         && submitRecords == null
-        && hashtags == null;
+        && hashtags == null
+        && topic == null;
   }
 
   private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentsInNotesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentsInNotesUtil.java
index e65f8e82..d0cb1df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentsInNotesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentsInNotesUtil.java
@@ -243,9 +243,6 @@
     }
 
     int startChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
-    if (startChar == 0) {
-      return null;
-    }
     if (note[ptr.value] == '-') {
       range.setStartCharacter(startChar);
       ptr.value += 1;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
new file mode 100644
index 0000000..3bf2135
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
@@ -0,0 +1,64 @@
+// 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.notedb;
+
+import com.google.gerrit.extensions.client.ReviewerState;
+
+import org.eclipse.jgit.revwalk.FooterKey;
+
+import java.util.Arrays;
+
+/** State of a reviewer on a change. */
+public enum ReviewerStateInternal {
+  /** The user has contributed at least one nonzero vote on the change. */
+  REVIEWER(new FooterKey("Reviewer"), ReviewerState.REVIEWER),
+
+  /** The reviewer was added to the change, but has not voted. */
+  CC(new FooterKey("CC"), ReviewerState.CC),
+
+  /** The user was previously a reviewer on the change, but was removed. */
+  REMOVED(new FooterKey("Removed"), ReviewerState.REMOVED);
+
+  static {
+    boolean ok = true;
+    if (ReviewerStateInternal.values().length != ReviewerState.values().length) {
+      ok = false;
+    }
+    for (ReviewerStateInternal s : ReviewerStateInternal.values()) {
+      ok &= s.name().equals(s.state.name());
+    }
+    if (!ok) {
+      throw new IllegalStateException("Mismatched reviewer state mapping: "
+          + Arrays.asList(ReviewerStateInternal.values()) + " != "
+          + Arrays.asList(ReviewerState.values()));
+    }
+  }
+
+  private final FooterKey footerKey;
+  private final ReviewerState state;
+
+  private ReviewerStateInternal(FooterKey footerKey, ReviewerState state) {
+    this.footerKey = footerKey;
+    this.state = state;
+  }
+
+  FooterKey getFooterKey() {
+    return footerKey;
+  }
+
+  public ReviewerState asReviewerState() {
+    return state;
+  }
+}
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 a6332f0..3680dc3 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
@@ -20,12 +20,12 @@
 
 /** Provides a cached list of {@link PatchListEntry}. */
 public interface PatchListCache {
-  public PatchList get(PatchListKey key, Project.NameKey project)
+  PatchList get(PatchListKey key, Project.NameKey project)
       throws PatchListNotAvailableException;
 
-  public PatchList get(Change change, PatchSet patchSet)
+  PatchList get(Change change, PatchSet patchSet)
       throws PatchListNotAvailableException;
 
-  public IntraLineDiff getIntraLineDiff(IntraLineDiffKey key,
+  IntraLineDiff getIntraLineDiff(IntraLineDiffKey key,
       IntraLineDiffArgs args);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
index 5de28f3..d130175 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -51,7 +51,7 @@
 
   static PatchListEntry empty(final String fileName) {
     return new PatchListEntry(ChangeType.MODIFIED, PatchType.UNIFIED, null,
-        fileName, EMPTY_HEADER, Collections.<Edit> emptyList(), 0, 0, 0);
+        fileName, EMPTY_HEADER, Collections.<Edit> emptyList(), 0, 0, 0, 0);
   }
 
   private final ChangeType changeType;
@@ -62,11 +62,13 @@
   private final List<Edit> edits;
   private final int insertions;
   private final int deletions;
+  private final long size;
   private final long sizeDelta;
   // Note: When adding new fields, the serialVersionUID in PatchListKey must be
   // incremented so that entries from the cache are automatically invalidated.
 
-  PatchListEntry(FileHeader hdr, List<Edit> editList, long sizeDelta) {
+  PatchListEntry(FileHeader hdr, List<Edit> editList, long size,
+      long sizeDelta) {
     changeType = toChangeType(hdr);
     patchType = toPatchType(hdr);
 
@@ -111,12 +113,13 @@
     }
     insertions = ins;
     deletions = del;
+    this.size = size;
     this.sizeDelta = sizeDelta;
   }
 
   private PatchListEntry(ChangeType changeType, PatchType patchType,
       String oldName, String newName, byte[] header, List<Edit> edits,
-      int insertions, int deletions, long sizeDelta) {
+      int insertions, int deletions, long size, long sizeDelta) {
     this.changeType = changeType;
     this.patchType = patchType;
     this.oldName = oldName;
@@ -125,6 +128,7 @@
     this.edits = edits;
     this.insertions = insertions;
     this.deletions = deletions;
+    this.size = size;
     this.sizeDelta = sizeDelta;
   }
 
@@ -172,6 +176,10 @@
     return deletions;
   }
 
+  public long getSize() {
+    return size;
+  }
+
   public long getSizeDelta() {
     return sizeDelta;
   }
@@ -208,6 +216,7 @@
     writeBytes(out, header);
     writeVarInt32(out, insertions);
     writeVarInt32(out, deletions);
+    writeFixInt64(out, size);
     writeFixInt64(out, sizeDelta);
 
     writeVarInt32(out, edits.size());
@@ -227,6 +236,7 @@
     byte[] hdr = readBytes(in);
     int ins = readVarInt32(in);
     int del = readVarInt32(in);
+    long size = readFixInt64(in);
     long sizeDelta = readFixInt64(in);
 
     int editCount = readVarInt32(in);
@@ -240,7 +250,7 @@
     }
 
     return new PatchListEntry(changeType, patchType, oldName, newName, hdr,
-        toList(editArray), ins, del, sizeDelta);
+        toList(editArray), ins, del, size, sizeDelta);
   }
 
   private static List<Edit> toList(Edit[] l) {
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 15277b2..0200fa5 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
@@ -34,7 +34,7 @@
 import java.io.Serializable;
 
 public class PatchListKey implements Serializable {
-  static final long serialVersionUID = 18L;
+  static final long serialVersionUID = 19L;
 
   public static final BiMap<Whitespace, Character> WHITESPACE_TYPES = ImmutableBiMap.of(
       Whitespace.IGNORE_NONE, 'N',
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 aa2a8a8..10ea61c 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
@@ -206,7 +206,7 @@
               getFileSize(repo, reader, e.getOldMode(), e.getOldPath(), aTree);
           long newSize =
               getFileSize(repo, reader, e.getNewMode(), e.getNewPath(), bTree);
-          entries.add(newEntry(aTree, fh, newSize - oldSize));
+          entries.add(newEntry(aTree, fh, newSize, newSize - oldSize));
         }
       }
       return new PatchList(a, b, againstParent,
@@ -301,37 +301,38 @@
     byte[] rawHdr = hdr.toString().getBytes(UTF_8);
     byte[] aContent = aText.getContent();
     byte[] bContent = bText.getContent();
+    long size = bContent.length;
     long sizeDelta = bContent.length - aContent.length;
     RawText aRawText = new RawText(aContent);
     RawText bRawText = new RawText(bContent);
     EditList edits = new HistogramDiff().diff(cmp, aRawText, bRawText);
     FileHeader fh = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
-    return new PatchListEntry(fh, edits, sizeDelta);
+    return new PatchListEntry(fh, edits, size, sizeDelta);
   }
 
   private PatchListEntry newEntry(RevTree aTree, FileHeader fileHeader,
-      long sizeDelta) {
+      long size, long sizeDelta) {
     final FileMode oldMode = fileHeader.getOldMode();
     final FileMode newMode = fileHeader.getNewMode();
 
     if (oldMode == FileMode.GITLINK || newMode == FileMode.GITLINK) {
       return new PatchListEntry(fileHeader, Collections.<Edit> emptyList(),
-          sizeDelta);
+          size, sizeDelta);
     }
 
     if (aTree == null // want combined diff
         || fileHeader.getPatchType() != PatchType.UNIFIED
         || fileHeader.getHunks().isEmpty()) {
       return new PatchListEntry(fileHeader, Collections.<Edit> emptyList(),
-          sizeDelta);
+          size, sizeDelta);
     }
 
     List<Edit> edits = fileHeader.toEditList();
     if (edits.isEmpty()) {
       return new PatchListEntry(fileHeader, Collections.<Edit> emptyList(),
-          sizeDelta);
+          size, sizeDelta);
     } else {
-      return new PatchListEntry(fileHeader, edits, sizeDelta);
+      return new PatchListEntry(fileHeader, edits, size, sizeDelta);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 6b458aa..5df364f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.registration.ReloadableRegistrationHandle;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.util.PluginRequestContext;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -77,6 +78,7 @@
   private final List<StartPluginListener> onStart;
   private final List<StopPluginListener> onStop;
   private final List<ReloadPluginListener> onReload;
+  private final MetricMaker serverMetrics;
 
   private Module sysModule;
   private Module sshModule;
@@ -102,12 +104,14 @@
       Injector sysInjector,
       ThreadLocalRequestContext local,
       ServerInformation srvInfo,
-      CopyConfigModule ccm) {
+      CopyConfigModule ccm,
+      MetricMaker serverMetrics) {
     this.sysInjector = sysInjector;
     this.srvInfo = srvInfo;
     this.local = local;
     this.copyConfigModule = ccm;
     this.copyConfigKeys = Guice.createInjector(ccm).getAllBindings().keySet();
+    this.serverMetrics = serverMetrics;
 
     onStart = new CopyOnWriteArrayList<>();
     onStart.addAll(listeners(sysInjector, StartPluginListener.class));
@@ -127,6 +131,10 @@
     return srvInfo;
   }
 
+  MetricMaker getServerMetrics() {
+    return serverMetrics;
+  }
+
   boolean hasDynamicItem(TypeLiteral<?> type) {
     return sysItems.containsKey(type)
         || (sshItems != null && sshItems.containsKey(type))
@@ -424,6 +432,7 @@
       }
     }
   }
+
   private void reattachItem(
       ListMultimap<TypeLiteral<?>, ReloadableRegistrationHandle<?>> oldHandles,
       Map<TypeLiteral<?>, DynamicItem<?>> items,
@@ -564,6 +573,9 @@
     if (StopPluginListener.class.isAssignableFrom(type)) {
       return false;
     }
+    if (MetricMaker.class.isAssignableFrom(type)) {
+      return false;
+    }
 
     if (type.getName().startsWith("com.google.inject.")) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
new file mode 100644
index 0000000..724ebeb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
@@ -0,0 +1,204 @@
+// 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.plugins;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.metrics.CallbackMetric;
+import com.google.gerrit.metrics.CallbackMetric0;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram0;
+import com.google.gerrit.metrics.Histogram1;
+import com.google.gerrit.metrics.Histogram2;
+import com.google.gerrit.metrics.Histogram3;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.metrics.Timer3;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+
+public class PluginMetricMaker extends MetricMaker implements LifecycleListener {
+  private final MetricMaker root;
+  private final String prefix;
+  private final Set<RegistrationHandle> cleanup;
+
+  public PluginMetricMaker(MetricMaker root, String pluginName) {
+    this.root = root;
+    this.prefix = String.format("plugins/%s/", pluginName);
+    cleanup = Collections.synchronizedSet(new HashSet<RegistrationHandle>());
+  }
+
+  @Override
+  public Counter0 newCounter(String name, Description desc) {
+    Counter0 m = root.newCounter(prefix + name, desc);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1> Counter1<F1> newCounter(
+      String name, Description desc,
+      Field<F1> field1) {
+    Counter1<F1> m = root.newCounter(prefix + name, desc, field1);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2> Counter2<F1, F2> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    Counter2<F1, F2> m = root.newCounter(prefix + name, desc, field1, field2);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    Counter3<F1, F2, F3> m =
+        root.newCounter(prefix + name, desc, field1, field2, field3);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public Timer0 newTimer(String name, Description desc) {
+    Timer0 m = root.newTimer(prefix + name, desc);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1> Timer1<F1> newTimer(
+      String name, Description desc,
+      Field<F1> field1) {
+    Timer1<F1> m = root.newTimer(prefix + name, desc, field1);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2> Timer2<F1, F2> newTimer(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    Timer2<F1, F2> m = root.newTimer(prefix + name, desc, field1, field2);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    Timer3<F1, F2, F3> m =
+        root.newTimer(prefix + name, desc, field1, field2, field3);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public Histogram0 newHistogram(String name, Description desc) {
+    Histogram0 m = root.newHistogram(prefix + name, desc);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1> Histogram1<F1> newHistogram(
+      String name, Description desc,
+      Field<F1> field1) {
+    Histogram1<F1> m = root.newHistogram(prefix + name, desc, field1);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2> Histogram2<F1, F2> newHistogram(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2) {
+    Histogram2<F1, F2> m = root.newHistogram(prefix + name, desc, field1, field2);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
+      String name, Description desc,
+      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    Histogram3<F1, F2, F3> m =
+        root.newHistogram(prefix + name, desc, field1, field2, field3);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <V> CallbackMetric0<V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc) {
+    CallbackMetric0<V> m = root.newCallbackMetric(prefix + name, valueClass, desc);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(String name,
+      Class<V> valueClass, Description desc, Field<F1> field1) {
+    CallbackMetric1<F1, V> m =
+        root.newCallbackMetric(prefix + name, valueClass, desc, field1);
+    cleanup.add(m);
+    return m;
+  }
+
+  @Override
+  public RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics,
+      Runnable trigger) {
+    final RegistrationHandle handle = root.newTrigger(metrics, trigger);
+    cleanup.add(handle);
+    return new RegistrationHandle() {
+      @Override
+      public void remove() {
+        handle.remove();
+        cleanup.remove(handle);
+      }
+    };
+  }
+
+  @Override
+  public void start() {
+  }
+
+  @Override
+  public void stop() {
+    synchronized (cleanup) {
+      Iterator<RegistrationHandle> itr = cleanup.iterator();
+      while (itr.hasNext()) {
+        itr.next().remove();
+        itr.remove();
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPluginListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPluginListener.java
index 72a499e..73fb9c4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPluginListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPluginListener.java
@@ -16,5 +16,5 @@
 
 /** Broadcasts event indicating a plugin was reloaded. */
 public interface ReloadPluginListener {
-  public void onReloadPlugin(Plugin oldPlugin, Plugin newPlugin);
+  void onReloadPlugin(Plugin oldPlugin, Plugin newPlugin);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
index 14c1185..ea96a56 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -236,7 +236,7 @@
     if (getApiType() == ApiType.PLUGIN) {
       modules.add(env.getSysModule());
     }
-    modules.add(new ServerPluginInfoModule(this));
+    modules.add(new ServerPluginInfoModule(this, env.getServerMetrics()));
     return Guice.createInjector(modules);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
index b0e9453..a7f0087 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
@@ -17,6 +17,8 @@
 import com.google.gerrit.extensions.annotations.PluginCanonicalWebUrl;
 import com.google.gerrit.extensions.annotations.PluginData;
 import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.PluginUser;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
@@ -32,10 +34,12 @@
   private final Path dataDir;
 
   private volatile boolean ready;
+  private final MetricMaker serverMetrics;
 
-  ServerPluginInfoModule(ServerPlugin plugin) {
+  ServerPluginInfoModule(ServerPlugin plugin, MetricMaker serverMetrics) {
     this.plugin = plugin;
     this.dataDir = plugin.getDataDir();
+    this.serverMetrics = serverMetrics;
   }
 
   @Override
@@ -47,6 +51,17 @@
     bind(String.class)
       .annotatedWith(PluginCanonicalWebUrl.class)
       .toInstance(plugin.getPluginCanonicalWebUrl());
+
+    install(new LifecycleModule() {
+      @Override
+      public void configure() {
+        PluginMetricMaker metrics = new PluginMetricMaker(
+            serverMetrics,
+            plugin.getName());
+        bind(MetricMaker.class).toInstance(metrics);
+        listener().toInstance(metrics);
+      }
+    });
   }
 
   @Provides
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StartPluginListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StartPluginListener.java
index aaad370..0d27c87 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StartPluginListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StartPluginListener.java
@@ -16,5 +16,5 @@
 
 /** Broadcasts event indicating a plugin was loaded. */
 public interface StartPluginListener {
-  public void onStartPlugin(Plugin plugin);
+  void onStartPlugin(Plugin plugin);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StopPluginListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StopPluginListener.java
index 24bd655..7ce53a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StopPluginListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/StopPluginListener.java
@@ -16,5 +16,5 @@
 
 /** Broadcasts event indicating a plugin was unloaded. */
 public interface StopPluginListener {
-  public void onStopPlugin(Plugin plugin);
+  void onStopPlugin(Plugin plugin);
 }
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 3ab6ff5..606ca78d 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
@@ -28,6 +28,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -89,7 +90,7 @@
         throws NoSuchChangeException, OrmException {
       ChangeControl c = controlFor(change, user);
       if (!c.isVisible(db.get())) {
-        throw new NoSuchChangeException(c.getChange().getId());
+        throw new NoSuchChangeException(c.getId());
       }
       return c;
     }
@@ -103,23 +104,27 @@
   private final ChangeData.Factory changeDataFactory;
   private final RefControl refControl;
   private final ChangeNotes notes;
+  private final ApprovalsUtil approvalsUtil;
 
   @AssistedInject
   ChangeControl(
       ChangeData.Factory changeDataFactory,
       ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
       @Assisted RefControl refControl,
       @Assisted Change change) {
-    this(changeDataFactory, refControl,
+    this(changeDataFactory, approvalsUtil, refControl,
         notesFactory.create(change));
   }
 
   @AssistedInject
   ChangeControl(
       ChangeData.Factory changeDataFactory,
+      ApprovalsUtil approvalsUtil,
       @Assisted RefControl refControl,
       @Assisted ChangeNotes notes) {
     this.changeDataFactory = changeDataFactory;
+    this.approvalsUtil = approvalsUtil;
     this.refControl = refControl;
     this.notes = notes;
   }
@@ -128,7 +133,7 @@
     if (getUser().equals(who)) {
       return this;
     }
-    return new ChangeControl(changeDataFactory,
+    return new ChangeControl(changeDataFactory, approvalsUtil,
         getRefControl().forUser(who), notes);
   }
 
@@ -148,6 +153,10 @@
     return getProjectControl().getProject();
   }
 
+  public Change.Id getId() {
+    return notes.getChangeId();
+  }
+
   public Change getChange() {
     return notes.getChange();
   }
@@ -190,13 +199,13 @@
   }
 
   /** Can this user abandon this change? */
-  public boolean canAbandon() {
-    return isOwner() // owner (aka creator) of the change can abandon
+  public boolean canAbandon(ReviewDb db) throws OrmException {
+    return (isOwner() // owner (aka creator) of the change can abandon
         || getRefControl().isOwner() // branch owner can abandon
         || getProjectControl().isOwner() // project owner can abandon
         || getUser().getCapabilities().canAdministrateServer() // site administers are god
         || getRefControl().canAbandon() // user can abandon a specific ref
-    ;
+        ) && !isPatchSetLocked(db);
   }
 
   /** Can this user publish this draft change or any draft patch set of this change? */
@@ -212,14 +221,14 @@
   }
 
   /** Can this user rebase this change? */
-  public boolean canRebase() {
-    return isOwner() || getRefControl().canSubmit()
-        || getRefControl().canRebase();
+  public boolean canRebase(ReviewDb db) throws OrmException {
+    return (isOwner() || getRefControl().canSubmit()
+        || getRefControl().canRebase()) && !isPatchSetLocked(db);
   }
 
   /** Can this user restore this change? */
-  public boolean canRestore() {
-    return canAbandon() // Anyone who can abandon the change can restore it back
+  public boolean canRestore(ReviewDb db) throws OrmException {
+    return canAbandon(db) // Anyone who can abandon the change can restore it back
         && getRefControl().canUpload(); // as long as you can upload too
   }
 
@@ -258,8 +267,25 @@
   }
 
   /** Can this user add a patch set to this change? */
-  public boolean canAddPatchSet() {
-    return getRefControl().canUpload();
+  public boolean canAddPatchSet(ReviewDb db) throws OrmException {
+    return getRefControl().canUpload() && !isPatchSetLocked(db);
+  }
+
+  /** Is the current patch set locked against state changes? */
+  public boolean isPatchSetLocked(ReviewDb db) throws OrmException {
+    if (getChange().getStatus() == Change.Status.MERGED) {
+      return false;
+    }
+
+    for (PatchSetApproval ap : approvalsUtil.byPatchSet(db, this,
+        getChange().currentPatchSetId())) {
+      LabelType type = getLabelTypes().byLabel(ap.getLabel());
+      if (type != null && ap.getValue() == 1
+          && type.getFunctionName().equalsIgnoreCase("PatchSetLock")) {
+        return true;
+      }
+    }
+    return false;
   }
 
   /** Is this user the owner of the change? */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
index d451b46..67bdc88 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
@@ -23,10 +23,10 @@
 /** Cache of project information, including access rights. */
 public interface ProjectCache {
   /** @return the parent state for all projects on this server. */
-  public ProjectState getAllProjects();
+  ProjectState getAllProjects();
 
   /** @return the project state of the project storing meta data for all users. */
-  public ProjectState getAllUsers();
+  ProjectState getAllUsers();
 
   /**
    * Get the cached data for a project by its unique name.
@@ -35,7 +35,7 @@
    * @return the cached data; null if no such project exists or a error occurred.
    * @see #checkedGet(com.google.gerrit.reviewdb.client.Project.NameKey)
    */
-  public ProjectState get(Project.NameKey projectName);
+  ProjectState get(Project.NameKey projectName);
 
   /**
    * Get the cached data for a project by its unique name.
@@ -44,14 +44,14 @@
    * @throws IOException when there was an error.
    * @return the cached data; null if no such project exists.
    */
-  public ProjectState checkedGet(Project.NameKey projectName)
+  ProjectState checkedGet(Project.NameKey projectName)
       throws IOException;
 
   /** Invalidate the cached information about the given project. */
-  public void evict(Project p);
+  void evict(Project p);
 
   /** Invalidate the cached information about the given project. */
-  public void evict(Project.NameKey p);
+  void evict(Project.NameKey p);
 
   /**
    * Remove information about the given project from the cache. It will no
@@ -60,14 +60,14 @@
   void remove(Project p);
 
   /** @return sorted iteration of projects. */
-  public abstract Iterable<Project.NameKey> all();
+  Iterable<Project.NameKey> all();
 
   /**
    * @return estimated set of relevant groups extracted from hot project access
    *         rules. If the cache is cold or too small for the entire project set
    *         of the server, this set may be incomplete.
    */
-  public abstract Set<AccountGroup.UUID> guessRelevantGroupUUIDs();
+  Set<AccountGroup.UUID> guessRelevantGroupUUIDs();
 
   /**
    * Filter the set of registered project names by common prefix.
@@ -75,8 +75,8 @@
    * @param prefix common prefix.
    * @return sorted iteration of projects sharing the same prefix.
    */
-  public abstract Iterable<Project.NameKey> byName(String prefix);
+  Iterable<Project.NameKey> byName(String prefix);
 
   /** Notify the cache that a new project was constructed. */
-  public void onCreateProject(Project.NameKey newProjectName);
+  void onCreateProject(Project.NameKey newProjectName);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
index 76ad2f0..5f921b1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+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;
@@ -89,6 +90,7 @@
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<CurrentUser> user;
   private final ChangeHooks hooks;
+  private final GitReferenceUpdated gitRefUpdated;
 
   @Inject
   PutConfig(@EnableSignedPush boolean serverEnableSignedPush,
@@ -102,6 +104,7 @@
       AllProjectsNameProvider allProjects,
       DynamicMap<RestView<ProjectResource>> views,
       ChangeHooks hooks,
+      GitReferenceUpdated gitRefUpdated,
       Provider<CurrentUser> user) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
@@ -114,6 +117,7 @@
     this.allProjects = allProjects;
     this.views = views;
     this.hooks = hooks;
+    this.gitRefUpdated = gitRefUpdated;
     this.user = user;
   }
 
@@ -199,6 +203,8 @@
         ObjectId commitRev = projectConfig.commit(md);
         // Only fire hook if project was actually changed.
         if (!Objects.equals(baseRev, commitRev)) {
+          gitRefUpdated.fire(projectName, RefNames.REFS_CONFIG,
+              baseRev, commitRev);
           hooks.doRefUpdatedHook(
             new Branch.NameKey(projectName, RefNames.REFS_CONFIG),
             baseRev, commitRev, user.get().asIdentifiedUser().getAccount());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
index d589865..2d407bd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
+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;
@@ -46,15 +47,18 @@
   private final MetaDataUpdate.Server updateFactory;
   private final GitRepositoryManager gitMgr;
   private final ChangeHooks hooks;
+  private final GitReferenceUpdated gitRefUpdated;
 
   @Inject
   PutDescription(ProjectCache cache,
       MetaDataUpdate.Server updateFactory,
       ChangeHooks hooks,
+      GitReferenceUpdated gitRefUpdated,
       GitRepositoryManager gitMgr) {
     this.cache = cache;
     this.updateFactory = updateFactory;
     this.hooks = hooks;
+    this.gitRefUpdated = gitRefUpdated;
     this.gitMgr = gitMgr;
   }
 
@@ -91,6 +95,8 @@
         ObjectId commitRev = config.commit(md);
         // Only fire hook if project was actually changed.
         if (!Objects.equals(baseRev, commitRev)) {
+          gitRefUpdated.fire(resource.getNameKey(), RefNames.REFS_CONFIG,
+              baseRev, commitRev);
           hooks.doRefUpdatedHook(
             new Branch.NameKey(resource.getNameKey(), RefNames.REFS_CONFIG),
             baseRev, commitRev, user.getAccount());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/DataSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/DataSource.java
index 83cdc80..8a1718d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/DataSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/DataSource.java
@@ -19,8 +19,8 @@
 
 public interface DataSource<T> {
   /** @return an estimate of the number of results from {@link #read()}. */
-  public int getCardinality();
+  int getCardinality();
 
   /** @return read from the database and return the results. */
-  public ResultSet<T> read() throws OrmException;
+  ResultSet<T> read() throws OrmException;
 }
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 c8f9972..3a21ce4 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
@@ -73,6 +73,11 @@
  * @param <T> type of object the predicates can evaluate in memory.
  */
 public abstract class QueryBuilder<T> {
+  /** Converts a value string passed to an operator into a {@link Predicate}. */
+  public interface OperatorFactory<T, Q extends QueryBuilder<T>> {
+    Predicate<T> create(Q builder, String value) throws QueryParseException;
+  }
+
   /**
    * Defines the operators known by a QueryBuilder.
    *
@@ -162,7 +167,7 @@
   protected final Definition<T, ? extends QueryBuilder<T>> builderDef;
 
   @SuppressWarnings("rawtypes")
-  private final Map<String, OperatorFactory> opFactories;
+  protected final Map<String, OperatorFactory> opFactories;
 
   @SuppressWarnings({"unchecked", "rawtypes"})
   protected QueryBuilder(Definition<T, ? extends QueryBuilder<T>> def) {
@@ -323,11 +328,6 @@
     return new QueryParseException(msg, why);
   }
 
-  /** Converts a value string passed to an operator into a {@link Predicate}. */
-  protected interface OperatorFactory<T, Q extends QueryBuilder<T>> {
-    Predicate<T> create(Q builder, String value) throws QueryParseException;
-  }
-
   /** Denotes a method which is a query operator. */
   @Retention(RetentionPolicy.RUNTIME)
   @Target(ElementType.METHOD)
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 9677f5f..a3fb523 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
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.data.SubmitRecord;
@@ -44,7 +45,7 @@
 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.notedb.ReviewerState;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -415,7 +416,7 @@
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
     this.mergeabilityCache = mergeabilityCache;
-    legacyId = c.getChange().getId();
+    legacyId = c.getId();
     change = c.getChange();
     changeControl = c;
     notes = c.getNotes();
@@ -543,6 +544,23 @@
     return changeControl;
   }
 
+  public ChangeControl changeControl(CurrentUser user) throws OrmException {
+    if (changeControl != null) {
+      throw new IllegalStateException(
+          "user already specified: " + changeControl.getUser());
+    }
+    try {
+      if (change != null) {
+        changeControl = changeControlFactory.controlFor(change, user);
+      } else {
+        changeControl = changeControlFactory.controlFor(legacyId, user);
+      }
+    } catch (NoSuchChangeException e) {
+      throw new OrmException(e);
+    }
+    return changeControl;
+  }
+
   void cacheVisibleTo(ChangeControl ctl) {
     visibleTo = ctl.getUser();
     changeControl = ctl;
@@ -706,7 +724,7 @@
     return allApprovals;
   }
 
-  public SetMultimap<ReviewerState, Account.Id> reviewers()
+  public SetMultimap<ReviewerStateInternal, Account.Id> reviewers()
       throws OrmException {
     return approvalsUtil.getReviewers(notes(), approvals().values());
   }
@@ -808,10 +826,7 @@
           events.add(ReviewedByEvent.create(msg));
         }
       }
-      for (PatchSet ps : patchSets()) {
-        events.add(ReviewedByEvent.create(ps));
-      }
-      Collections.sort(events, Collections.reverseOrder());
+      events = Lists.reverse(events);
       reviewedBy = new LinkedHashSet<>();
       Account.Id owner = c.getOwner();
       for (ReviewedByEvent event : events) {
@@ -829,12 +844,7 @@
   }
 
   @AutoValue
-  abstract static class ReviewedByEvent implements Comparable<ReviewedByEvent> {
-    private static ReviewedByEvent create(PatchSet ps) {
-      return new AutoValue_ChangeData_ReviewedByEvent(
-          ps.getUploader(), ps.getCreatedOn());
-    }
-
+  abstract static class ReviewedByEvent {
     private static ReviewedByEvent create(ChangeMessage msg) {
       return new AutoValue_ChangeData_ReviewedByEvent(
           msg.getAuthor(), msg.getWrittenOn());
@@ -842,11 +852,6 @@
 
     public abstract Account.Id author();
     public abstract Timestamp ts();
-
-    @Override
-    public int compareTo(ReviewedByEvent other) {
-      return ts().compareTo(other.ts());
-    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java
index 47bf82d..c32ff0d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java
@@ -18,5 +18,5 @@
 
 public interface ChangeDataSource extends DataSource<ChangeData> {
   /** @return true if all returned ChangeData.hasChange() will be true. */
-  public boolean hasChange();
+  boolean hasChange();
 }
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 a4cff07..440731e 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
@@ -25,6 +25,7 @@
 import com.google.gerrit.common.data.GroupReference;
 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;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -86,6 +87,10 @@
  * Parses a query string meant to be applied to change objects.
  */
 public class ChangeQueryBuilder extends QueryBuilder<ChangeData> {
+  public interface ChangeOperatorFactory
+      extends OperatorFactory<ChangeData, ChangeQueryBuilder> {
+  }
+
   private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$");
   private static final Pattern PAT_CHANGE_ID =
       Pattern.compile("^[iI][0-9a-f]{4,}.*$");
@@ -151,6 +156,7 @@
     final Provider<ReviewDb> db;
     final Provider<InternalChangeQuery> queryProvider;
     final IndexRewriter rewriter;
+    final DynamicMap<ChangeOperatorFactory> opFactories;
     final IdentifiedUser.GenericFactory userFactory;
     final CapabilityControl.Factory capabilityControlFactory;
     final ChangeControl.GenericFactory changeControlGenericFactory;
@@ -180,6 +186,7 @@
     public Arguments(Provider<ReviewDb> db,
         Provider<InternalChangeQuery> queryProvider,
         IndexRewriter rewriter,
+        DynamicMap<ChangeOperatorFactory> opFactories,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         CapabilityControl.Factory capabilityControlFactory,
@@ -202,7 +209,7 @@
         IndexConfig indexConfig,
         Provider<ListMembers> listMembers,
         @GerritServerConfig Config cfg) {
-      this(db, queryProvider, rewriter, userFactory, self,
+      this(db, queryProvider, rewriter, opFactories, userFactory, self,
           capabilityControlFactory, changeControlGenericFactory,
           changeDataFactory, fillArgs, plcUtil, accountResolver, groupBackend,
           allProjectsName, allUsersName, patchListCache, repoManager,
@@ -217,6 +224,7 @@
         Provider<ReviewDb> db,
         Provider<InternalChangeQuery> queryProvider,
         IndexRewriter rewriter,
+        DynamicMap<ChangeOperatorFactory> opFactories,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         CapabilityControl.Factory capabilityControlFactory,
@@ -242,6 +250,7 @@
      this.db = db;
      this.queryProvider = queryProvider;
      this.rewriter = rewriter;
+     this.opFactories = opFactories;
      this.userFactory = userFactory;
      this.self = self;
      this.capabilityControlFactory = capabilityControlFactory;
@@ -267,7 +276,7 @@
     }
 
     Arguments asUser(CurrentUser otherUser) {
-      return new Arguments(db, queryProvider, rewriter, userFactory,
+      return new Arguments(db, queryProvider, rewriter, opFactories, userFactory,
           Providers.of(otherUser),
           capabilityControlFactory, changeControlGenericFactory,
           changeDataFactory, fillArgs, plcUtil, accountResolver, groupBackend,
@@ -320,6 +329,7 @@
   ChangeQueryBuilder(Arguments args) {
     super(mydef);
     this.args = args;
+    setupDynamicOperators();
   }
 
   @VisibleForTesting
@@ -330,6 +340,13 @@
     this.args = args;
   }
 
+  private void setupDynamicOperators() {
+    for (DynamicMap.Entry<ChangeOperatorFactory> e : args.opFactories) {
+      String name = e.getExportName() + "_" + e.getPluginName();
+      opFactories.put(name, e.getProvider().get());
+    }
+  }
+
   public ChangeQueryBuilder asUser(CurrentUser user) {
     return new ChangeQueryBuilder(builderDef, args.asUser(user));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCache.java
index bf7a5dd..e8b2fef 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCache.java
@@ -18,8 +18,8 @@
 
 public interface ConflictsCache {
 
-  public void put(ConflictKey key, Boolean value);
+  void put(ConflictKey key, Boolean value);
 
   @Nullable
-  public Boolean getIfPresent(ConflictKey key);
+  Boolean getIfPresent(ConflictKey key);
 }
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 34a2bdf..ff6f2bc 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
@@ -106,6 +106,11 @@
     return this;
   }
 
+  public InternalChangeQuery setRequestedFields(Set<String> fields) {
+    qp.setRequestedFields(fields);
+    return this;
+  }
+
   public List<ChangeData> byKey(Change.Key key) throws OrmException {
     return byKeyPrefix(key.get());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryOptions.java
index 1964fa5..a70f892 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryOptions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryOptions.java
@@ -17,29 +17,36 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.index.IndexConfig;
 
+import java.util.Set;
+
 @AutoValue
 public abstract class QueryOptions {
-  public static QueryOptions create(IndexConfig config, int start, int limit) {
+  public static QueryOptions create(IndexConfig config, int start, int limit,
+      Set<String> fields) {
     checkArgument(start >= 0, "start must be nonnegative: %s", start);
     checkArgument(limit > 0, "limit must be positive: %s", limit);
-    return new AutoValue_QueryOptions(config, start, limit);
+    return new AutoValue_QueryOptions(config, start, limit,
+        ImmutableSet.copyOf(fields));
   }
 
   public static QueryOptions oneResult() {
-    return create(IndexConfig.createDefault(), 0, 1);
+    return create(IndexConfig.createDefault(), 0, 1,
+        ImmutableSet.<String> of());
   }
 
   public abstract IndexConfig config();
   public abstract int start();
   public abstract int limit();
+  public abstract ImmutableSet<String> fields();
 
   public QueryOptions withLimit(int newLimit) {
-    return create(config(), start(), newLimit);
+    return create(config(), start(), newLimit, fields());
   }
 
   public QueryOptions withStart(int newStart) {
-    return create(config(), newStart, limit());
+    return create(config(), newStart, limit(), fields());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java
index a2c8b81..1a6ae02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java
@@ -18,10 +18,16 @@
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.index.ChangeIndex;
+import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.IndexRewriter;
@@ -32,32 +38,41 @@
 import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
 
 public class QueryProcessor {
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> userProvider;
   private final ChangeControl.GenericFactory changeControlFactory;
+  private final IndexCollection indexes;
   private final IndexRewriter rewriter;
   private final IndexConfig indexConfig;
+  private final Metrics metrics;
 
   private int limitFromCaller;
   private int start;
   private boolean enforceVisibility = true;
+  private Set<String> requestedFields;
 
   @Inject
   QueryProcessor(Provider<ReviewDb> db,
       Provider<CurrentUser> userProvider,
       ChangeControl.GenericFactory changeControlFactory,
+      IndexCollection indexes,
       IndexRewriter rewriter,
-      IndexConfig indexConfig) {
+      IndexConfig indexConfig,
+      Metrics metrics) {
     this.db = db;
     this.userProvider = userProvider;
     this.changeControlFactory = changeControlFactory;
+    this.indexes = indexes;
     this.rewriter = rewriter;
     this.indexConfig = indexConfig;
+    this.metrics = metrics;
   }
 
   public QueryProcessor enforceVisibility(boolean enforce) {
@@ -75,6 +90,11 @@
     return this;
   }
 
+  public QueryProcessor setRequestedFields(Set<String> fields) {
+    requestedFields = fields;
+    return this;
+  }
+
   /**
    * Query for changes that match a structured query.
    *
@@ -114,6 +134,9 @@
   private List<QueryResult> queryChanges(List<String> queryStrings,
       List<Predicate<ChangeData>> queries)
       throws OrmException, QueryParseException {
+    @SuppressWarnings("resource")
+    Timer0.Context context = metrics.executionTime.start();
+
     Predicate<ChangeData> visibleToMe = enforceVisibility
         ? new IsVisibleToPredicate(db, changeControlFactory, userProvider.get())
         : null;
@@ -140,7 +163,8 @@
             "Cannot go beyond page " + indexConfig.maxPages() + "of results");
       }
 
-      QueryOptions opts = QueryOptions.create(indexConfig, start, limit + 1);
+      QueryOptions opts = QueryOptions.create(
+          indexConfig, start, limit + 1, getRequestedFields());
       Predicate<ChangeData> s = rewriter.rewrite(q, opts);
       if (!(s instanceof ChangeDataSource)) {
         q = Predicate.and(open(), q);
@@ -170,9 +194,20 @@
           limits.get(i),
           matches.get(i).toList()));
     }
+    context.close(); // only measure successful queries
     return out;
   }
 
+  private Set<String> getRequestedFields() {
+    if (requestedFields != null) {
+      return requestedFields;
+    }
+    ChangeIndex index = indexes.getSearchIndex();
+    return index != null
+        ? index.getSchema().getStoredFields().keySet()
+        : ImmutableSet.<String> of();
+  }
+
   boolean isDisabled() {
     return getPermittedLimit() <= 0;
   }
@@ -203,4 +238,19 @@
     }
     return Ordering.natural().min(possibleLimits);
   }
+
+  @Singleton
+  static class Metrics {
+    final Timer0 executionTime;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      executionTime = metricMaker.newTimer(
+          "change/query/query_latency",
+          new Description("Successful change query latency,"
+              + " accumulated over the life of the process")
+            .setCumulative()
+            .setUnit(Description.Units.MILLISECONDS));
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
index 30ebaa7..0c3bf67 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -20,9 +20,14 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.persistence.DataSourceInterceptor;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.ConfigSection;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gwtorm.jdbc.SimpleDataSource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -43,18 +48,22 @@
 @Singleton
 public class DataSourceProvider implements Provider<DataSource>,
     LifecycleListener {
-  public static final int DEFAULT_POOL_LIMIT = 8;
-
   private final Config cfg;
+  private final MetricMaker metrics;
   private final Context ctx;
   private final DataSourceType dst;
+  private final ThreadSettingsConfig threadSettingsConfig;
   private DataSource ds;
 
   @Inject
   protected DataSourceProvider(@GerritServerConfig Config cfg,
+      MetricMaker metrics,
+      ThreadSettingsConfig threadSettingsConfig,
       Context ctx,
       DataSourceType dst) {
     this.cfg = cfg;
+    this.metrics = metrics;
+    this.threadSettingsConfig = threadSettingsConfig;
     this.ctx = ctx;
     this.dst = dst;
   }
@@ -120,12 +129,15 @@
       if (password != null && !password.isEmpty()) {
         ds.setPassword(password);
       }
-      ds.setMaxActive(cfg.getInt("database", "poollimit", DEFAULT_POOL_LIMIT));
+      int poolLimit = threadSettingsConfig.getDatabasePoolLimit();
+      ds.setMaxActive(poolLimit);
       ds.setMinIdle(cfg.getInt("database", "poolminidle", 4));
-      ds.setMaxIdle(cfg.getInt("database", "poolmaxidle", 4));
+      ds.setMaxIdle(
+          cfg.getInt("database", "poolmaxidle", Math.min(poolLimit, 16)));
       ds.setMaxWait(ConfigUtil.getTimeUnit(cfg, "database", null,
           "poolmaxwait", MILLISECONDS.convert(30, SECONDS), MILLISECONDS));
       ds.setInitialSize(ds.getMinIdle());
+      exportPoolMetrics(ds);
       return intercept(interceptor, ds);
 
     } else {
@@ -148,6 +160,25 @@
     }
   }
 
+  private void exportPoolMetrics(final BasicDataSource pool) {
+    final CallbackMetric1<Boolean, Integer> cnt = metrics.newCallbackMetric(
+        "sql/connection_pool/connections",
+        Integer.class,
+        new Description("SQL database connections")
+          .setGauge()
+          .setUnit("connections"),
+        Field.ofBoolean("active"));
+    metrics.newTrigger(cnt, new Runnable() {
+      @Override
+      public void run() {
+        synchronized (pool) {
+          cnt.set(true, pool.getNumActive());
+          cnt.set(false, pool.getNumIdle());
+        }
+      }
+    });
+  }
+
   private DataSource intercept(String interceptor, DataSource ds) {
     if (interceptor == null) {
       return ds;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
index ecaaf5e..6eaa540 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
@@ -20,11 +20,11 @@
 /** Abstraction of a supported database platform */
 public interface DataSourceType {
 
-  public String getDriver();
+  String getDriver();
 
-  public String getUrl();
+  String getUrl();
 
-  public boolean usePool();
+  boolean usePool();
 
   /**
    * Return a ScriptRunner that runs the index script. Must not return
@@ -32,5 +32,5 @@
    *
    * @throws IOException
    */
-  public ScriptRunner getIndexScript() throws IOException;
+  ScriptRunner getIndexScript() throws IOException;
 }
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 27df9a5c..78646a0 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
@@ -32,7 +32,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_115> C = Schema_115.class;
+  public static final Class<Schema_116> C = Schema_116.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_116.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_116.java
new file mode 100644
index 0000000..c7c2a59
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_116.java
@@ -0,0 +1,43 @@
+// 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.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import java.sql.SQLException;
+import java.sql.Statement;
+
+public class Schema_116 extends SchemaVersion {
+  @Inject
+  Schema_116(Provider<Schema_115> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws SQLException {
+    ui.message("Migrate user preference copySelfOnEmail to emailStrategy");
+    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement()) {
+      stmt.executeUpdate("UPDATE accounts SET "
+          + "EMAIL_STRATEGY='ENABLED' "
+          + "WHERE (COPY_SELF_ON_EMAIL='N')");
+      stmt.executeUpdate("UPDATE accounts SET "
+          + "EMAIL_STRATEGY='CC_ON_OWN_COMMENTS' "
+          + "WHERE (COPY_SELF_ON_EMAIL='Y')");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCache.java
index 13c5703..a3c0a5a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCache.java
@@ -19,8 +19,8 @@
 
 /** Permits controlling the contents of the SSH key cache area. */
 public interface SshKeyCache {
-  public void evict(String username);
+  void evict(String username);
 
-  public AccountSshKey create(AccountSshKey.Id id, String encoded)
+  AccountSshKey create(AccountSshKey.Id id, String encoded)
       throws InvalidSshKeyException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
index 1c68a75..cd559ee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
@@ -20,9 +20,9 @@
 public class TreeFormatter {
 
   public static interface TreeNode {
-    public String getDisplayName();
-    public boolean isVisible();
-    public SortedSet<? extends TreeNode> getChildren();
+    String getDisplayName();
+    boolean isVisible();
+    SortedSet<? extends TreeNode> getChildren();
   }
 
   public static final String NOT_VISIBLE_NODE = "(x)";
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
index 11db3ee..03bdf37 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
@@ -30,6 +30,6 @@
    * @param args arguments for the group creation
    * @throws ValidationException if validation fails
    */
-  public void validateNewGroup(CreateGroupArgs args)
+  void validateNewGroup(CreateGroupArgs args)
       throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java
index c1d509e..1baab7c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java
@@ -32,6 +32,6 @@
    * @param toRemove the hashtags to be removed
    * @throws ValidationException if validation fails
    */
-  public void validateHashtags(Change change, Set<String> toAdd,
+  void validateHashtags(Change change, Set<String> toAdd,
       Set<String> toRemove) 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 5b3f158..398a303 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
@@ -55,6 +55,6 @@
    * @param args E-mail properties. Some are mutable.
    * @throws ValidationException if validation fails.
    */
-  public void validateOutgoingEmail(OutgoingEmailValidationListener.Args args)
+  void validateOutgoingEmail(OutgoingEmailValidationListener.Args args)
       throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
index d3c69c1..6012328 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
@@ -30,6 +30,6 @@
    * @param args arguments for the project creation
    * @throws ValidationException if validation fails
    */
-  public void validateNewProject(CreateProjectArgs args)
+  void validateNewProject(CreateProjectArgs args)
       throws ValidationException;
 }
diff --git a/gerrit-server/src/main/prolog/gerrit_common.pl b/gerrit-server/src/main/prolog/gerrit_common.pl
index 9a4e77c..59c926f 100644
--- a/gerrit-server/src/main/prolog/gerrit_common.pl
+++ b/gerrit-server/src/main/prolog/gerrit_common.pl
@@ -243,6 +243,7 @@
 legacy_submit_rule('MaxNoBlock', Label, Min, Max, T) :- !, max_no_block(Label, Max, T).
 legacy_submit_rule('NoBlock', Label, Min, Max, T) :- !, T = may(_).
 legacy_submit_rule('NoOp', Label, Min, Max, T) :- !, T = may(_).
+legacy_submit_rule('PatchSetLock', Label, Min, Max, T) :- !, T = may(_).
 legacy_submit_rule(Fun, Label, Min, Max, T) :- T = impossible(unsupported(Fun)).
 
 %% max_with_block:
@@ -294,6 +295,10 @@
 %%
 %% - At least one maximum is used.
 %%
+max_no_block(Max, Label, label(Label, S)) :-
+  number(Max), atom(Label),
+  !,
+  max_no_block(Label, Max, S).
 max_no_block(Label, Max, ok(Who)) :-
   check_label_range_permission(Label, Max, ok(Who)),
   !
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index b537f26..aace2b3 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -63,6 +63,10 @@
 		AWK=/usr/xpg4/bin/awk
 	fi
 
+	# Get core.commentChar from git config or use default symbol
+	commentChar=`git config --get core.commentChar`
+	commentChar=${commentChar:-#}
+
 	# How this works:
 	# - parse the commit message as (textLine+ blankLine*)*
 	# - assume textLine+ to be a footer until proven otherwise
@@ -81,8 +85,8 @@
 		blankLines = 0
 	}
 
-	# Skip lines starting with "#" without any spaces before it.
-	/^#/ { next }
+	# Skip lines starting with commentChar without any spaces before it.
+	/^'"$commentChar"'/ { next }
 
 	# Skip the line starting with the diff command and everything after it,
 	# up to the end of the file, assuming it is only patch data.
diff --git a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
new file mode 100644
index 0000000..aa4c4eb
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
@@ -0,0 +1,201 @@
+// 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.metrics.proc;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.common.Version;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.metrics.CallbackMetric0;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.FieldOrdering;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricRegistry;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class ProcMetricModuleTest {
+  @Rule
+  public ExpectedException exception = ExpectedException.none();
+
+  @Inject
+  MetricMaker metrics;
+
+  @Inject
+  MetricRegistry registry;
+
+  @Test
+  public void testConstantBuildLabel() {
+    Gauge<String> buildLabel = gauge("build/label");
+    assertThat(buildLabel.getValue()).isEqualTo(Version.getVersion());
+  }
+
+  @Test
+  public void testProcUptime() {
+    Gauge<Long> birth = gauge("proc/birth_timestamp");
+    assertThat(birth.getValue()).isAtMost(
+        TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()));
+
+    Gauge<Long> uptime = gauge("proc/uptime");
+    assertThat(uptime.getValue()).isAtLeast(1L);
+  }
+
+  @Test
+  public void testCounter0() {
+    Counter0 cntr = metrics.newCounter(
+        "test/count",
+        new Description("simple test")
+          .setCumulative());
+
+    Counter raw = get("test/count", Counter.class);
+    assertThat(raw.getCount()).isEqualTo(0);
+
+    cntr.increment();
+    assertThat(raw.getCount()).isEqualTo(1);
+
+    cntr.incrementBy(5);
+    assertThat(raw.getCount()).isEqualTo(6);
+  }
+
+  @Test
+  public void testCounter1() {
+    Counter1<String> cntr = metrics.newCounter(
+        "test/count",
+        new Description("simple test")
+          .setCumulative(),
+        Field.ofString("action"));
+
+    Counter total = get("test/count_total", Counter.class);
+    assertThat(total.getCount()).isEqualTo(0);
+
+    cntr.increment("passed");
+    Counter passed = get("test/count/passed", Counter.class);
+    assertThat(total.getCount()).isEqualTo(1);
+    assertThat(passed.getCount()).isEqualTo(1);
+
+    cntr.incrementBy("failed", 5);
+    Counter failed = get("test/count/failed", Counter.class);
+    assertThat(total.getCount()).isEqualTo(6);
+    assertThat(passed.getCount()).isEqualTo(1);
+    assertThat(failed.getCount()).isEqualTo(5);
+  }
+
+  @Test
+  public void testCounterPrefixFields() {
+    Counter1<String> cntr = metrics.newCounter(
+        "test/count",
+        new Description("simple test")
+          .setCumulative()
+          .setFieldOrdering(FieldOrdering.PREFIX_FIELDS_BASENAME),
+        Field.ofString("action"));
+
+    Counter total = get("test/count_total", Counter.class);
+    assertThat(total.getCount()).isEqualTo(0);
+
+    cntr.increment("passed");
+    Counter passed = get("test/passed/count", Counter.class);
+    assertThat(total.getCount()).isEqualTo(1);
+    assertThat(passed.getCount()).isEqualTo(1);
+
+    cntr.incrementBy("failed", 5);
+    Counter failed = get("test/failed/count", Counter.class);
+    assertThat(total.getCount()).isEqualTo(6);
+    assertThat(passed.getCount()).isEqualTo(1);
+    assertThat(failed.getCount()).isEqualTo(5);
+  }
+
+  @Test
+  public void testCallbackMetric0() {
+    final CallbackMetric0<Long> cntr = metrics.newCallbackMetric(
+        "test/count",
+        Long.class,
+        new Description("simple test")
+          .setCumulative());
+
+    final AtomicInteger invocations = new AtomicInteger(0);
+    metrics.newTrigger(cntr, new Runnable() {
+      @Override
+      public void run() {
+        invocations.getAndIncrement();
+        cntr.set(42L);
+      }
+    });
+
+    // Triggers run immediately with DropWizard binding.
+    assertThat(invocations.get()).isEqualTo(1);
+
+    Gauge<Long> raw = gauge("test/count");
+    assertThat(raw.getValue()).isEqualTo(42);
+
+    // Triggers are debounced to avoid being fired too frequently.
+    assertThat(invocations.get()).isEqualTo(1);
+  }
+
+  @Test
+  public void testInvalidName1() {
+    exception.expect(IllegalArgumentException.class);
+    metrics.newCounter("invalid name", new Description("fail"));
+  }
+
+  @Test
+  public void testInvalidName2() {
+    exception.expect(IllegalArgumentException.class);
+    metrics.newCounter("invalid/ name", new Description("fail"));
+  }
+
+  @SuppressWarnings({"unchecked", "cast"})
+  private <V> Gauge<V> gauge(String name) {
+    return (Gauge<V>) get(name, Gauge.class);
+  }
+
+  private <M extends Metric> M get(String name, Class<M> type) {
+    Metric m = registry.getMetrics().get(name);
+    assertThat(m).named(name).isNotNull();
+    assertThat(m).named(name).isInstanceOf(type);
+
+    @SuppressWarnings("unchecked")
+    M result = (M) m;
+    return result;
+  }
+
+  @Before
+  public void setup() {
+    Injector injector =
+        Guice.createInjector(new DropWizardMetricMaker.ApiModule());
+
+    LifecycleManager mgr = new LifecycleManager();
+    mgr.add(injector);
+    mgr.start();
+
+    injector.injectMembers(this);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
index d6a5c67..0245b89 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
@@ -40,9 +40,7 @@
 
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 import java.io.PushbackReader;
 import java.io.StringReader;
@@ -62,9 +60,6 @@
   private ProjectConfig local;
   private Util util;
 
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
   @Before
   public void setUp() throws Exception {
     util = new Util();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
index 8956e8f..dc8004a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.inject.Guice;
 import com.google.inject.Module;
 
@@ -45,7 +46,7 @@
 
 
 /** Base class for any tests written in Prolog. */
-public abstract class PrologTestCase {
+public abstract class PrologTestCase extends GerritBaseTests {
   private static final SymbolTerm test_1 = SymbolTerm.intern("test", 1);
 
   private String pkg;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
index 039871e..6349be2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
@@ -95,7 +95,8 @@
         bind(CapabilityControl.Factory.class)
           .toProvider(Providers.<CapabilityControl.Factory>of(null));
         bind(Realm.class).toInstance(mockRealm);
-
+        bind(StarredChangesUtil.class)
+            .toProvider(Providers.<StarredChangesUtil> of(null));
       }
     };
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/CommentsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/change/CommentsTest.java
index f5e6d74..052b126 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/change/CommentsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/change/CommentsTest.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 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.AccountLoader;
 import com.google.gerrit.server.account.CapabilityControl;
@@ -67,8 +68,8 @@
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeAccountCache;
+import com.google.gerrit.testutil.GerritServerTests;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gwtorm.server.ListResultSet;
@@ -87,10 +88,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
-import org.junit.runner.RunWith;
 
 import java.sql.Timestamp;
 import java.util.Collections;
@@ -98,23 +96,10 @@
 import java.util.Map;
 import java.util.TimeZone;
 
-@RunWith(ConfigSuite.class)
-public class CommentsTest  {
+public class CommentsTest extends GerritServerTests {
   private static final TimeZone TZ =
       TimeZone.getTimeZone("America/Los_Angeles");
 
-  @ConfigSuite.Parameter
-  public Config config;
-
-  @ConfigSuite.Config
-  @GerritServerConfig
-  public static Config noteDbEnabled() {
-    return NotesMigration.allEnabledConfig();
-  }
-
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
   private Injector injector;
   private ReviewDb db;
   private Project.NameKey project;
@@ -200,6 +185,8 @@
             .toInstance(GitReferenceUpdated.DISABLED);
         bind(PersonIdent.class).annotatedWith(GerritPersonIdent.class)
           .toInstance(serverIdent);
+        bind(StarredChangesUtil.class)
+          .toProvider(Providers.<StarredChangesUtil> of(null));
       }
 
       @Provides
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java
index 743e43d..8cdd42b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java
@@ -21,10 +21,9 @@
 import static org.junit.Assert.assertTrue;
 
 import com.google.gerrit.server.util.HostPlatform;
+import com.google.gerrit.testutil.GerritBaseTests;
 
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 import java.io.IOException;
 import java.nio.file.Files;
@@ -32,10 +31,7 @@
 import java.nio.file.Path;
 import java.nio.file.Paths;
 
-public class SitePathsTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
+public class SitePathsTest extends GerritBaseTests {
   @Test
   public void testCreate_NotExisting() throws IOException {
     final Path root = random();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/FakeQueryBuilder.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/FakeQueryBuilder.java
index 63ae818..dd03bb0 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/FakeQueryBuilder.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/FakeQueryBuilder.java
@@ -26,8 +26,8 @@
         new FakeQueryBuilder.Definition<>(
           FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(null, null, null, null, null, null,
-            null, null, null, null, null, null, null, null, null, null, null,
-            null, indexes, null, null, null, null, null, null));
+          null, null, null, null, null, null, null, null, null, null, null,
+          null, null, indexes, null, null, null, null, null, null));
   }
 
   @Operator
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/IndexRewriterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/IndexRewriterTest.java
index 9ac83d5..f44d172 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/IndexRewriterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/IndexRewriterTest.java
@@ -25,6 +25,7 @@
 import static com.google.gerrit.server.query.Predicate.or;
 import static org.junit.Assert.assertEquals;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.AndPredicate;
 import com.google.gerrit.server.query.Predicate;
@@ -34,22 +35,18 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.OrSource;
 import com.google.gerrit.server.query.change.QueryOptions;
+import com.google.gerrit.testutil.GerritBaseTests;
 
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.Set;
 
-public class IndexRewriterTest {
+public class IndexRewriterTest extends GerritBaseTests {
   private static final IndexConfig CONFIG = IndexConfig.createDefault();
 
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
   private FakeIndex index;
   private IndexCollection indexes;
   private ChangeQueryBuilder queryBuilder;
@@ -289,7 +286,8 @@
   }
 
   private static QueryOptions options(int start, int limit) {
-    return QueryOptions.create(CONFIG, start, limit);
+    return QueryOptions.create(CONFIG, start, limit,
+        ImmutableSet.<String> of());
   }
 
   private Set<Change.Status> status(String query) throws QueryParseException {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
index 145042c..d5f3132 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
@@ -15,15 +15,13 @@
 package com.google.gerrit.server.mail;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
 
-import org.junit.Rule;
+import com.google.gerrit.testutil.GerritBaseTests;
+
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
-public class AddressTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
+public class AddressTest extends GerritBaseTests {
   @Test
   public void testParse_NameEmail1() {
     final Address a = Address.parse("A U Thor <author@example.com>");
@@ -100,9 +98,12 @@
   }
 
   private void assertInvalid(final String in) {
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("Invalid email address: " + in);
-    Address.parse(in);
+    try {
+      Address.parse(in);
+      fail("Expected IllegalArgumentException for " + in);
+    } catch (IllegalArgumentException e) {
+      assertThat(e.getMessage()).isEqualTo("Invalid email address: " + in);
+    }
   }
 
   @Test
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 8030f9f..07624a4 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
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.inject.Scopes.SINGLETON;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableList;
@@ -32,6 +31,7 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.FakeRealm;
@@ -49,8 +49,10 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.testutil.FakeAccountCache;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.TestChanges;
+import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.StandardKeyEncoder;
@@ -62,17 +64,13 @@
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeUtils;
-import org.joda.time.DateTimeUtils.MillisProvider;
 import org.junit.After;
 import org.junit.Before;
 
 import java.sql.Timestamp;
 import java.util.TimeZone;
-import java.util.concurrent.atomic.AtomicLong;
 
-public class AbstractChangeNotesTest {
+public class AbstractChangeNotesTest extends GerritBaseTests {
   private static final TimeZone TZ =
       TimeZone.getTimeZone("America/Los_Angeles");
 
@@ -91,7 +89,6 @@
 
   private Injector injector;
   private String systemTimeZone;
-  private volatile long clockStepMs;
 
   @Inject private AllUsersNameProvider allUsers;
 
@@ -139,6 +136,8 @@
             .toInstance(serverIdent);
         bind(GitReferenceUpdated.class)
             .toInstance(GitReferenceUpdated.DISABLED);
+        bind(StarredChangesUtil.class)
+            .toProvider(Providers.<StarredChangesUtil> of(null));
       }
     });
 
@@ -151,21 +150,12 @@
 
   private void setTimeForTesting() {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    clockStepMs = MILLISECONDS.convert(1, SECONDS);
-    final AtomicLong clockMs = new AtomicLong(
-        new DateTime(2009, 9, 30, 17, 0, 0).getMillis());
-
-    DateTimeUtils.setCurrentMillisProvider(new MillisProvider() {
-      @Override
-      public long getMillis() {
-        return clockMs.getAndAdd(clockStepMs);
-      }
-    });
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
   }
 
   @After
   public void resetTime() {
-    DateTimeUtils.setCurrentMillisSystem();
+    TestTimeUtil.useSystemTime();
     System.setProperty("user.timezone", systemTimeZone);
   }
 
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 bac72f0..0f3fee4 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
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.notedb;
 
+import static org.junit.Assert.fail;
+
 import com.google.gerrit.common.TimeUtil;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -27,17 +29,12 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 public class ChangeNotesParserTest extends AbstractChangeNotesTest {
   private TestRepository<InMemoryRepository> testRepo;
   private RevWalk walk;
 
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
   @Before
   public void setUpTestRepo() throws Exception {
     testRepo = new TestRepository<>(repo);
@@ -171,6 +168,23 @@
         + "Reviewer: 1@gerrit\n");
   }
 
+  @Test
+  public void parseTopic() throws Exception {
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Patch-Set: 1\n"
+        + "Topic: Some Topic");
+    assertParseSucceeds("Update change\n"
+        + "\n"
+        + "Patch-Set: 1\n"
+        + "Topic:");
+    assertParseFails("Update change\n"
+        + "\n"
+        + "Patch-Set: 1\n"
+        + "Topic: Some Topic\n"
+        + "Topic: Other Topic");
+  }
+
   private RevCommit writeCommit(String body) throws Exception {
     return writeCommit(body, ChangeNoteUtil.newIdent(
         changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent,
@@ -205,8 +219,10 @@
 
   private void assertParseFails(RevCommit commit) throws Exception {
     try (ChangeNotesParser parser = newParser(commit)) {
-      exception.expect(ConfigInvalidException.class);
       parser.parseAll();
+      fail("Expected parse to fail:\n" + commit.getFullMessage());
+    } catch (ConfigInvalidException e) {
+      // Expected
     }
   }
 
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 f19987f..c68c283 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
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.notedb.ReviewerState.CC;
-import static com.google.gerrit.server.notedb.ReviewerState.REVIEWER;
+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;
 
@@ -371,6 +371,43 @@
   }
 
   @Test
+  public void topicChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+
+    // initially topic is not set
+    ChangeNotes notes = newNotes(c);
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isNull();
+
+    // set topic
+    String topic = "myTopic";
+    update.setTopic(topic);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isEqualTo(topic);
+
+    // clear topic by setting empty string
+    update.setTopic("");
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isNull();
+
+    // set other topic
+    topic = "otherTopic";
+    update.setTopic(topic);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isEqualTo(topic);
+
+    // clear topic by setting null
+    update.setTopic(null);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isNull();
+  }
+
+  @Test
   public void emptyExceptSubject() throws Exception {
     ChangeUpdate update = newUpdate(newChange(), changeOwner);
     update.setSubject("Create change");
@@ -526,7 +563,7 @@
 
     ChangeNotes notes = newNotes(c);
     ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessages();
+        notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages.keySet()).containsExactly(ps1);
 
     ChangeMessage cm = Iterables.getOnlyElement(changeMessages.get(ps1));
@@ -557,7 +594,7 @@
 
     ChangeNotes notes = newNotes(c);
     ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessages();
+        notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages).hasSize(1);
 
     ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
@@ -579,7 +616,7 @@
 
     ChangeNotes notes = newNotes(c);
     ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessages();
+        notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages).hasSize(1);
 
     ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
@@ -609,7 +646,7 @@
 
     ChangeNotes notes = newNotes(c);
     ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessages();
+        notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages).hasSize(2);
 
     ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
@@ -642,7 +679,7 @@
 
     ChangeNotes notes = newNotes(c);
     ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessages();
+        notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages.keySet()).hasSize(1);
 
     List<ChangeMessage> cm = changeMessages.get(ps1);
@@ -690,7 +727,7 @@
     update.commit();
 
     update = newUpdate(c, otherUser);
-    CommentRange range3 = new CommentRange(3, 1, 4, 1);
+    CommentRange range3 = new CommentRange(3, 0, 4, 1);
     PatchLineComment comment3 = newPublishedComment(psId, "file2",
         uuid3, range3, range3.getEndLine(), otherUser, null, time3, message3,
         (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
@@ -729,7 +766,7 @@
           + "\n"
           + "File: file2\n"
           + "\n"
-          + "3:1-4:1\n"
+          + "3:0-4:1\n"
           + CommentsInNotesUtil.formatTime(serverIdent, time3) + "\n"
           + "Author: Other Account <2@gerrit>\n"
           + "UUID: uuid3\n"
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 c206902..811cd8a 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
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.notedb.ReviewerState.CC;
-import static com.google.gerrit.server.notedb.ReviewerState.REVIEWER;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.TimeUtil;
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 a671523..b3c94ed 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
@@ -37,6 +37,7 @@
 import com.google.gerrit.rules.PrologEnvironment;
 import com.google.gerrit.rules.RulesCache;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.FakeRealm;
@@ -96,6 +97,14 @@
         value(-2, "This shall not be merged"));
   }
 
+  public static final LabelType patchSetLock() {
+    LabelType label = category("Patch-Set-Lock",
+        value(1, "Patch Set Locked"),
+        value(0, "Patch Set Unlocked"));
+    label.setFunctionName("PatchSetLock");
+    return label;
+  }
+
   public static LabelValue value(int value, String text) {
     return new LabelValue((short) value, text);
   }
@@ -317,6 +326,8 @@
         bind(ChangeKindCache.class).to(ChangeKindCacheImpl.NoCache.class);
         bind(MergeabilityCache.class)
           .to(MergeabilityCache.NotImplemented.class);
+        bind(StarredChangesUtil.class)
+          .toProvider(Providers.<StarredChangesUtil> of(null));
       }
     });
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java
index 382610f..47df2db 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java
@@ -21,33 +21,13 @@
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 import org.junit.Test;
 
-import java.util.Collections;
 import java.util.List;
 
 public class AndPredicateTest extends PredicateTest {
-  private static final class TestPredicate extends OperatorPredicate<String> {
-    private TestPredicate(String name, String value) {
-      super(name, value);
-    }
-
-    @Override
-    public boolean match(String object) {
-      return false;
-    }
-
-    @Override
-    public int getCost() {
-      return 0;
-    }
-  }
-
-  private static TestPredicate f(final String name, final String value) {
-    return new TestPredicate(name, value);
-  }
-
   @Test
   public void testChildren() {
     final TestPredicate a = f("author", "alice");
@@ -64,17 +44,29 @@
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = and(a, b);
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().clear();
+    try {
+      n.getChildren().clear();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
     assertChildren("clear", n, of(a, b));
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().remove(0);
+    try {
+      n.getChildren().remove(0);
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
     assertChildren("remove(0)", n, of(a, b));
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().iterator().remove();
-    assertChildren("remove(0)", n, of(a, b));
+    try {
+      n.getChildren().iterator().remove();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
+    assertChildren("iterator().remove()", n, of(a, b));
   }
 
   private static void assertChildren(String o, Predicate<String> p,
@@ -129,11 +121,5 @@
     assertNotSame(n2, n2.copy(s2));
     assertEquals(s2, n2.copy(s2).getChildren());
     assertEquals(s3, n2.copy(s3).getChildren());
-
-    try {
-      n2.copy(Collections.<Predicate<String>> emptyList());
-    } catch (IllegalArgumentException e) {
-      assertEquals("Need at least two predicates", e.getMessage());
-    }
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java
index e31caaf..8f16670 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java
@@ -23,27 +23,7 @@
 
 import java.util.Collections;
 
-public class FieldPredicateTest {
-  private static final class TestPredicate extends OperatorPredicate<String> {
-    private TestPredicate(String name, String value) {
-      super(name, value);
-    }
-
-    @Override
-    public boolean match(String object) {
-      return false;
-    }
-
-    @Override
-    public int getCost() {
-      return 0;
-    }
-  }
-
-  private static TestPredicate f(final String name, final String value) {
-    return new TestPredicate(name, value);
-  }
-
+public class FieldPredicateTest extends PredicateTest {
   @Test
   public void testToString() {
     assertEquals("author:bob", f("author", "bob").toString());
@@ -81,10 +61,8 @@
     assertSame(f, f.copy(Collections.<Predicate<String>> emptyList()));
     assertSame(f, f.copy(f.getChildren()));
 
-    try {
-      f.copy(Collections.singleton(f("owner", "bob")));
-    } catch (IllegalArgumentException e) {
-      assertEquals("Expected 0 children", e.getMessage());
-    }
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("Expected 0 children");
+    f.copy(Collections.singleton(f("owner", "bob")));
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java
index 45a747c..0256081 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java
@@ -21,6 +21,7 @@
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 import org.junit.Test;
 
@@ -28,26 +29,6 @@
 import java.util.List;
 
 public class NotPredicateTest extends PredicateTest {
-  private static final class TestPredicate extends OperatorPredicate<String> {
-    private TestPredicate(String name, String value) {
-      super(name, value);
-    }
-
-    @Override
-    public boolean match(String object) {
-      return false;
-    }
-
-    @Override
-    public int getCost() {
-      return 0;
-    }
-  }
-
-  private static TestPredicate f(final String name, final String value) {
-    return new TestPredicate(name, value);
-  }
-
   @Test
   public void testNotNot() {
     final TestPredicate p = f("author", "bob");
@@ -125,12 +106,14 @@
 
     try {
       n.copy(Collections.<Predicate> emptyList());
+      fail("Expected IllegalArgumentException");
     } catch (IllegalArgumentException e) {
       assertEquals("Expected exactly one child", e.getMessage());
     }
 
     try {
       n.copy(and(a, b).getChildren());
+      fail("Expected IllegalArgumentException");
     } catch (IllegalArgumentException e) {
       assertEquals("Expected exactly one child", e.getMessage());
     }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java
index ee5e0b0..5640d1b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java
@@ -21,33 +21,13 @@
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 import org.junit.Test;
 
-import java.util.Collections;
 import java.util.List;
 
 public class OrPredicateTest extends PredicateTest {
-  private static final class TestPredicate extends OperatorPredicate<String> {
-    private TestPredicate(String name, String value) {
-      super(name, value);
-    }
-
-    @Override
-    public boolean match(String object) {
-      return false;
-    }
-
-    @Override
-    public int getCost() {
-      return 0;
-    }
-  }
-
-  private static TestPredicate f(final String name, final String value) {
-    return new TestPredicate(name, value);
-  }
-
   @Test
   public void testChildren() {
     final TestPredicate a = f("author", "alice");
@@ -64,17 +44,29 @@
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = or(a, b);
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().clear();
+    try {
+      n.getChildren().clear();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
     assertChildren("clear", n, of(a, b));
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().remove(0);
+    try {
+      n.getChildren().remove(0);
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
     assertChildren("remove(0)", n, of(a, b));
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().iterator().remove();
-    assertChildren("remove(0)", n, of(a, b));
+    try {
+      n.getChildren().iterator().remove();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
+    assertChildren("iterator().remove()", n, of(a, b));
   }
 
   private static void assertChildren(String o, Predicate<String> p,
@@ -129,11 +121,5 @@
     assertNotSame(n2, n2.copy(s2));
     assertEquals(s2, n2.copy(s2).getChildren());
     assertEquals(s3, n2.copy(s3).getChildren());
-
-    try {
-      n2.copy(Collections.<Predicate<String>> emptyList());
-    } catch (IllegalArgumentException e) {
-      assertEquals("Need at least two predicates", e.getMessage());
-    }
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
index 865841e..9a601eb 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
@@ -14,10 +14,26 @@
 
 package com.google.gerrit.server.query;
 
-import org.junit.Rule;
-import org.junit.rules.ExpectedException;
+import com.google.gerrit.testutil.GerritBaseTests;
 
-public class PredicateTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
+public class PredicateTest extends GerritBaseTests {
+  protected static final class TestPredicate extends OperatorPredicate<String> {
+    protected TestPredicate(String name, String value) {
+      super(name, value);
+    }
+
+    @Override
+    public boolean match(String object) {
+      return false;
+    }
+
+    @Override
+    public int getCost() {
+      return 0;
+    }
+  }
+
+  protected static TestPredicate f(String name, String value) {
+    return new TestPredicate(name, value);
+  }
 }
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 d01fb03..6b91a12 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,6 +21,7 @@
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.Function;
 import com.google.common.base.MoreObjects;
@@ -58,6 +59,7 @@
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.group.AddMembers;
+import com.google.gerrit.server.index.ChangeField;
 import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
@@ -68,9 +70,11 @@
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.DisabledReviewDb;
+import com.google.gerrit.testutil.GerritServerTests;
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -80,44 +84,24 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeUtils;
-import org.joda.time.DateTimeUtils.MillisProvider;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
-import org.junit.runner.RunWith;
 
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
-import java.util.concurrent.atomic.AtomicLong;
 
 @Ignore
-@RunWith(ConfigSuite.class)
-public abstract class AbstractQueryChangesTest {
+public abstract class AbstractQueryChangesTest extends GerritServerTests {
   @ConfigSuite.Default
   public static Config defaultConfig() {
-    return updateConfig(new Config());
-  }
-
-  @ConfigSuite.Config
-  public static Config noteDbEnabled() {
-    return updateConfig(NotesMigration.allEnabledConfig());
-  }
-
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
-  private static Config updateConfig(Config cfg) {
+    Config cfg = new Config();
     cfg.setInt("index", null, "maxPages", 10);
     return cfg;
   }
 
-  @ConfigSuite.Parameter public Config config;
   @Inject protected AccountManager accountManager;
   @Inject protected BatchUpdate.Factory updateFactory;
   @Inject protected ChangeInserter.Factory changeFactory;
@@ -142,7 +126,6 @@
   protected ReviewDb db;
   protected Account.Id userId;
   protected CurrentUser user;
-  protected volatile long clockStepMs;
 
   private String systemTimeZone;
 
@@ -198,21 +181,12 @@
   @Before
   public void setTimeForTesting() {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
-    clockStepMs = 1;
-    final AtomicLong clockMs = new AtomicLong(
-        new DateTime(2009, 9, 30, 17, 0, 0).getMillis());
-
-    DateTimeUtils.setCurrentMillisProvider(new MillisProvider() {
-      @Override
-      public long getMillis() {
-        return clockMs.getAndAdd(clockStepMs);
-      }
-    });
+    TestTimeUtil.resetWithClockStep(1, MILLISECONDS);
   }
 
   @After
   public void resetTime() {
-    DateTimeUtils.setCurrentMillisSystem();
+    TestTimeUtil.useSystemTime();
     System.setProperty("user.timezone", systemTimeZone);
   }
 
@@ -718,7 +692,7 @@
 
   @Test
   public void updateOrder() throws Exception {
-    clockStepMs = MILLISECONDS.convert(2, MINUTES);
+    TestTimeUtil.resetWithClockStep(2, MINUTES);
     TestRepository<Repo> repo = createProject("repo");
     List<ChangeInserter> inserters = Lists.newArrayList();
     List<Change> changes = Lists.newArrayList();
@@ -743,7 +717,7 @@
 
   @Test
   public void updatedOrderWithMinuteResolution() throws Exception {
-    clockStepMs = MILLISECONDS.convert(2, MINUTES);
+    TestTimeUtil.resetWithClockStep(2, MINUTES);
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChange(repo, null, null, null, null);
     Change change1 = insert(ins1);
@@ -897,16 +871,17 @@
 
   @Test
   public void byAge() throws Exception {
-    long thirtyHours = MILLISECONDS.convert(30, HOURS);
-    clockStepMs = thirtyHours;
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+    TestTimeUtil.resetWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(newChange(repo, null, null, null, null));
     Change change2 = insert(newChange(repo, null, null, null, null));
-    clockStepMs = 0; // Queried by AgePredicate constructor.
+    // Queried by AgePredicate constructor.
+    TestTimeUtil.setClockStep(0, MILLISECONDS);
     long now = TimeUtil.nowMs();
     assertThat(lastUpdatedMs(change2) - lastUpdatedMs(change1))
-        .isEqualTo(thirtyHours);
-    assertThat(now - lastUpdatedMs(change2)).isEqualTo(thirtyHours);
+        .isEqualTo(thirtyHoursInMs);
+    assertThat(now - lastUpdatedMs(change2)).isEqualTo(thirtyHoursInMs);
     assertThat(TimeUtil.nowMs()).isEqualTo(now);
 
     assertQuery("-age:1d");
@@ -920,11 +895,11 @@
 
   @Test
   public void byBefore() throws Exception {
-    clockStepMs = MILLISECONDS.convert(30, HOURS);
+    TestTimeUtil.resetWithClockStep(30, HOURS);
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(newChange(repo, null, null, null, null));
     Change change2 = insert(newChange(repo, null, null, null, null));
-    clockStepMs = 0;
+    TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     assertQuery("before:2009-09-29");
     assertQuery("before:2009-09-30");
@@ -940,11 +915,11 @@
 
   @Test
   public void byAfter() throws Exception {
-    clockStepMs = MILLISECONDS.convert(30, HOURS);
+    TestTimeUtil.resetWithClockStep(30, HOURS);
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(newChange(repo, null, null, null, null));
     Change change2 = insert(newChange(repo, null, null, null, null));
-    clockStepMs = 0;
+    TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     assertQuery("after:2009-10-03");
     assertQuery("after:\"2009-10-01 20:59:59 -0400\"", change2);
@@ -1219,7 +1194,7 @@
 
   @Test
   public void reviewedBy() throws Exception {
-    clockStepMs = MILLISECONDS.convert(2, MINUTES);
+    TestTimeUtil.resetWithClockStep(2, MINUTES);
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(newChange(repo, null, null, null, null));
     Change change2 = insert(newChange(repo, null, null, null, null));
@@ -1333,6 +1308,30 @@
     cd.messages();
   }
 
+  @Test
+  public void prepopulateOnlyRequestedFields() throws Exception {
+    assume().that(notesMigration.enabled()).isFalse();
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(newChange(repo, null, null, null, null));
+
+    db = new DisabledReviewDb();
+    requestContext.setContext(newRequestContext(userId));
+    // Use QueryProcessor directly instead of API so we get ChangeDatas back.
+    List<ChangeData> cds = queryProcessor
+        .setRequestedFields(ImmutableSet.of(
+            ChangeField.PATCH_SET.getName(),
+            ChangeField.CHANGE.getName()))
+        .queryChanges(queryBuilder.parse(change.getId().toString()))
+        .changes();
+    assertThat(cds).hasSize(1);
+
+    ChangeData cd = cds.get(0);
+    cd.change();
+    cd.patchSets();
+
+    exception.expect(DisabledReviewDb.Disabled.class);
+    cd.currentApprovals();
+  }
 
   protected ChangeInserter newChange(
       TestRepository<Repo> repo,
@@ -1414,8 +1413,12 @@
   }
 
   protected void assertBadQuery(QueryRequest query) throws Exception {
-    exception.expect(BadRequestException.class);
-    query.get();
+    try {
+      query.get();
+      fail("expected BadRequestException for query: " + query);
+    } catch (BadRequestException e) {
+      // Expected.
+    }
   }
 
   protected TestRepository<Repo> createProject(String name) throws Exception {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesV14Test.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesV14Test.java
index 7e7899b..ed4e520 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesV14Test.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesV14Test.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
 
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -25,6 +24,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 
@@ -78,9 +78,16 @@
     // Ignore.
   }
 
+  @Override
+  @Ignore
+  @Test
+  public void prepopulateOnlyRequestedFields() throws Exception {
+    // Ignore.
+  }
+
   @Test
   public void isReviewed() throws Exception {
-    clockStepMs = MILLISECONDS.convert(2, MINUTES);
+    TestTimeUtil.resetWithClockStep(2, MINUTES);
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(newChange(repo, null, null, null, null));
     Change change2 = insert(newChange(repo, null, null, null, null));
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java
index c060aaf..3e3c13e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java
@@ -24,9 +24,9 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
-import org.junit.Rule;
+import com.google.gerrit.testutil.GerritBaseTests;
+
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
 import java.net.Inet4Address;
 import java.net.Inet6Address;
@@ -34,10 +34,7 @@
 import java.net.InetSocketAddress;
 import java.net.UnknownHostException;
 
-public class SocketUtilTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
-
+public class SocketUtilTest extends GerritBaseTests {
   @Test
   public void testIsIPv6() throws UnknownHostException {
     final InetAddress ipv6 = getByName("1:2:3:4:5:6:7:8");
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
new file mode 100644
index 0000000..c7eb899
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
@@ -0,0 +1,23 @@
+// 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.testutil;
+
+import org.junit.Rule;
+import org.junit.rules.ExpectedException;
+
+public class GerritBaseTests {
+  @Rule
+  public ExpectedException exception = ExpectedException.none();
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
new file mode 100644
index 0000000..39989a9
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
@@ -0,0 +1,69 @@
+// 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.testutil;
+
+import com.google.gerrit.server.notedb.NotesMigration;
+
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runner.RunWith;
+import org.junit.runners.model.Statement;
+
+import java.util.Arrays;
+
+@RunWith(ConfigSuite.class)
+public class GerritServerTests extends GerritBaseTests {
+  @ConfigSuite.Parameter
+  public Config config;
+
+  @ConfigSuite.Name
+  private String configName;
+
+  public static boolean isNoteDbTestEnabled() {
+    final String[] RUN_FLAGS = {"yes", "y", "true"};
+    String value = System.getenv("GERRIT_ENABLE_NOTEDB");
+    return value != null &&
+        Arrays.asList(RUN_FLAGS).contains(value.toLowerCase());
+  }
+
+  @Rule
+  public TestRule testRunner = new TestRule() {
+    @Override
+    public Statement apply(final Statement base, final Description description) {
+      return new Statement() {
+        @Override
+        public void evaluate() throws Throwable {
+          beforeTest();
+          try {
+            base.evaluate();
+          } finally {
+            afterTest();
+          }
+        }
+      };
+    }
+  };
+
+  public void beforeTest() throws Exception {
+    if (isNoteDbTestEnabled()) {
+      NotesMigration.setAllEnabledConfig(config);
+    }
+  }
+
+  public void afterTest() {
+  }
+}
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 e5cd619..9d9ccb5 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
@@ -22,6 +22,8 @@
 import com.google.gerrit.common.DisabledChangeHooks;
 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;
@@ -132,6 +134,7 @@
             .toInstance(cfg);
       }
     });
+    bind(MetricMaker.class).to(DisabledMetricMaker.class);
     install(cfgInjector.getInstance(GerritGlobalModule.class));
     install(new ChangeCacheImplModule(false));
     factory(GarbageCollection.Factory.class);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
index 8b5e85a..f35dbea 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
@@ -102,12 +102,13 @@
       GitRepositoryManager repoManager, NotesMigration migration,
       Change c, AllUsersNameProvider allUsers,
       IdentifiedUser user) throws OrmException {
-    ChangeControl ctl = EasyMock.createNiceMock(ChangeControl.class);
+    ChangeControl ctl = EasyMock.createMock(ChangeControl.class);
     expect(ctl.getChange()).andStubReturn(c);
     expect(ctl.getUser()).andStubReturn(user);
     ChangeNotes notes = new ChangeNotes(repoManager, migration, allUsers, c)
         .load();
     expect(ctl.getNotes()).andStubReturn(notes);
+    expect(ctl.getId()).andStubReturn(c.getId());
     EasyMock.replay(ctl);
     return ctl;
   }
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
new file mode 100644
index 0000000..4c71c57
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
@@ -0,0 +1,75 @@
+// 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.testutil;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeUtils;
+import org.joda.time.DateTimeUtils.MillisProvider;
+import org.joda.time.DateTimeZone;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+/** Static utility methods for dealing with dates and times in tests. */
+public class TestTimeUtil {
+  private static Long clockStepMs;
+  private static AtomicLong clockMs;
+
+  /**
+   * Reset the clock to a known start point, then set the clock step.
+   * <p>
+   * The clock is initially set to 2009/09/30 17:00:00 -0400.
+   *
+   * @param clockStep amount to increment clock by on each lookup.
+   * @param clockStepUnit time unit for {@code clockStep}.
+   */
+  public static synchronized void resetWithClockStep(
+      long clockStep, TimeUnit clockStepUnit) {
+    // Set an arbitrary start point so tests are more repeatable.
+    clockMs = new AtomicLong(
+        new DateTime(2009, 9, 30, 17, 0, 0, DateTimeZone.forOffsetHours(-4))
+            .getMillis());
+    setClockStep(clockStep, clockStepUnit);
+  }
+
+  /**
+   * Set the clock step used by {@link com.google.gerrit.common.TimeUtil}.
+   *
+   * @param clockStep amount to increment clock by on each lookup.
+   * @param clockStepUnit time unit for {@code clockStep}.
+   */
+  public static synchronized void setClockStep(
+      long clockStep, TimeUnit clockStepUnit) {
+    checkState(clockMs != null, "call resetWithClockStep first");
+    clockStepMs = MILLISECONDS.convert(clockStep, clockStepUnit);
+    DateTimeUtils.setCurrentMillisProvider(new MillisProvider() {
+      @Override
+      public long getMillis() {
+        return clockMs.getAndAdd(clockStepMs);
+      }
+    });
+  }
+
+  /** Reset the clock to use the actual system clock. */
+  public static synchronized void useSystemTime() {
+    DateTimeUtils.setCurrentMillisSystem();
+  }
+
+  private TestTimeUtil() {
+  }
+}
diff --git a/gerrit-sshd/BUCK b/gerrit-sshd/BUCK
index 5a7b539..260b557 100644
--- a/gerrit-sshd/BUCK
+++ b/gerrit-sshd/BUCK
@@ -20,6 +20,7 @@
     '//lib:jsch',
     '//lib/auto:auto-value',
     '//lib/commons:codec',
+    '//lib/dropwizard:dropwizard-core',
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
     '//lib/guice:guice-servlet',  # SSH should not depend on servlet
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 5c897e4..ec49f5c 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
@@ -489,16 +489,16 @@
 
   /** Runnable function which can throw an exception. */
   public static interface CommandRunnable {
-    public void run() throws Exception;
+    void run() throws Exception;
   }
 
   /** Runnable function which can retrieve a project name related to the task */
   public static interface ProjectCommandRunnable extends CommandRunnable {
     // execute parser command before running, in order to be able to retrieve
     // project name
-    public void executeParseCommand() throws Exception;
+    void executeParseCommand() throws Exception;
 
-    public Project.NameKey getProjectName();
+    Project.NameKey getProjectName();
   }
 
   /** Thrown from {@link CommandRunnable#run()} with client message and code. */
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
index f78aba4..88e1142 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gerrit.server.git.QueueProvider;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
@@ -31,11 +32,13 @@
   private final WorkQueue.Executor batchExecutor;
 
   @Inject
-  public CommandExecutorQueueProvider(@GerritServerConfig final Config config,
-      final WorkQueue queues) {
-    final int cores = Runtime.getRuntime().availableProcessors();
-    poolSize = config.getInt("sshd", "threads", 3 * cores / 2);
-    batchThreads = config.getInt("sshd", "batchThreads", cores == 1 ? 1 : 2);
+  public CommandExecutorQueueProvider(
+      @GerritServerConfig Config config,
+      ThreadSettingsConfig threadsSettingsConfig,
+      WorkQueue queues) {
+    poolSize = threadsSettingsConfig.getSshdThreads();
+    batchThreads = config.getInt("sshd", "batchThreads",
+        threadsSettingsConfig.getSshdBatchTreads());
     if (batchThreads > poolSize) {
       poolSize += batchThreads;
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index 6bd9c4c..ee4984b79 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -217,7 +217,7 @@
 
     private void log(final int rc) {
       if (logged.compareAndSet(false, true)) {
-        log.onExecute(cmd, rc);
+        log.onExecute(cmd, rc, ctx.getSession());
       }
     }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
index d6b3e5c..33347e7 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
@@ -19,10 +19,14 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
@@ -128,6 +132,7 @@
 import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
 
 /**
  * SSH daemon to communicate with Gerrit.
@@ -172,7 +177,8 @@
       final KeyPairProvider hostKeyProvider, final IdGenerator idGenerator,
       @GerritServerConfig final Config cfg, final SshLog sshLog,
       @SshListenAddresses final List<SocketAddress> listen,
-      @SshAdvertisedAddresses final List<String> advertised) {
+      @SshAdvertisedAddresses final List<String> advertised,
+      MetricMaker metricMaker) {
     setPort(IANA_SSH_PORT /* never used */);
 
     this.cfg = cfg;
@@ -247,10 +253,39 @@
     setKeyPairProvider(hostKeyProvider);
     setCommandFactory(commandFactory);
     setShellFactory(noShell);
+
+    final AtomicInteger connected = new AtomicInteger();
+    metricMaker.newCallbackMetric(
+        "sshd/sessions/connected",
+        Integer.class,
+        new Description("Currently connected SSH sessions")
+          .setGauge()
+          .setUnit("sessions"),
+        new Supplier<Integer>() {
+          @Override
+          public Integer get() {
+            return connected.get();
+          }
+        });
+
+    final Counter0 sessionsCreated = metricMaker.newCounter(
+        "sshd/sessions/created",
+        new Description("Rate of new SSH sessions")
+          .setRate()
+          .setUnit("sessions"));
+
+    final Counter0 authFailures = metricMaker.newCounter(
+        "sshd/sessions/authentication_failures",
+        new Description("Rate of SSH authentication failures")
+          .setRate()
+          .setUnit("failures"));
+
     setSessionFactory(new SessionFactory() {
       @Override
       protected AbstractSession createSession(final IoSession io)
           throws Exception {
+        connected.incrementAndGet();
+        sessionsCreated.increment();
         if (io instanceof MinaSession) {
           if (((MinaSession) io).getSession()
               .getConfig() instanceof SocketSessionConfig) {
@@ -271,7 +306,9 @@
         s.addCloseSessionListener(new SshFutureListener<CloseFuture>() {
           @Override
           public void operationComplete(CloseFuture future) {
+            connected.decrementAndGet();
             if (sd.isAuthenticationError()) {
+              authFailures.increment();
               sshLog.onAuthFail(sd);
             }
           }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
index 3a8a1f5..cec3249 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
@@ -47,6 +47,7 @@
   private static final String P_WAIT = "queueWaitTime";
   private static final String P_EXEC = "executionTime";
   private static final String P_STATUS = "status";
+  private static final String P_AGENT = "agent";
 
   private final Provider<SshSession> session;
   private final Provider<Context> context;
@@ -115,7 +116,7 @@
     audit(null, "FAIL", "AUTH");
   }
 
-  void onExecute(DispatchCommand dcmd, int exitValue) {
+  void onExecute(DispatchCommand dcmd, int exitValue, SshSession sshSession) {
     final Context ctx = context.get();
     ctx.finished = TimeUtil.nowMs();
 
@@ -144,6 +145,10 @@
         break;
     }
     event.setProperty(P_STATUS, status);
+    String peerAgent = sshSession.getPeerAgent();
+    if (peerAgent != null) {
+      event.setProperty(P_AGENT, peerAgent);
+    }
 
     if (async != null) {
       async.append(event);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
index 2622fbd..541081e 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
@@ -30,6 +30,7 @@
   private static final String P_WAIT = "queueWaitTime";
   private static final String P_EXEC = "executionTime";
   private static final String P_STATUS = "status";
+  private static final String P_AGENT = "agent";
 
   private final Calendar calendar;
   private long lastTimeMillis;
@@ -63,6 +64,7 @@
     opt(P_WAIT, buf, event);
     opt(P_EXEC, buf, event);
     opt(P_STATUS, buf, event);
+    opt(P_AGENT, buf, event);
 
     buf.append('\n');
     return buf.toString();
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
index ff160e0..6285b20 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
@@ -35,6 +35,7 @@
   private volatile CurrentUser identity;
   private volatile String username;
   private volatile String authError;
+  private volatile String peerAgent;
 
   SshSession(final int sessionId, SocketAddress peer) {
     this.sessionId = sessionId;
@@ -72,6 +73,14 @@
     return remoteAsString;
   }
 
+  public String getPeerAgent() {
+    return peerAgent;
+  }
+
+  public void setPeerAgent(String agent) {
+    peerAgent = agent;
+  }
+
   String getUsername() {
     return username;
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
index abb788d..0b12aa6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
@@ -14,18 +14,15 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.Capable;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.AsyncReceiveCommits;
 import com.google.gerrit.server.git.ReceiveCommits;
-import com.google.gerrit.server.git.ReceivePackInitializer;
-import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshSession;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.errors.TooLargeObjectInPackException;
@@ -33,8 +30,6 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.PostReceiveHook;
-import org.eclipse.jgit.transport.PostReceiveHookChain;
 import org.eclipse.jgit.transport.ReceivePack;
 import org.kohsuke.args4j.Option;
 import org.slf4j.Logger;
@@ -62,13 +57,7 @@
   private IdentifiedUser.GenericFactory identifiedUserFactory;
 
   @Inject
-  private TransferConfig config;
-
-  @Inject
-  private DynamicSet<ReceivePackInitializer> receivePackInitializers;
-
-  @Inject
-  private DynamicSet<PostReceiveHook> postReceiveHooks;
+  private SshSession session;
 
   private final Set<Account.Id> reviewerId = new HashSet<>();
   private final Set<Account.Id> ccId = new HashSet<>();
@@ -100,19 +89,13 @@
     verifyProjectVisible("reviewer", reviewerId);
     verifyProjectVisible("CC", ccId);
 
+    receive.init();
     receive.addReviewers(reviewerId);
     receive.addExtraCC(ccId);
-
-    final ReceivePack rp = receive.getReceivePack();
-    rp.setRefLogIdent(currentUser.newRefLogIdent());
-    rp.setTimeout(config.getTimeout());
-    rp.setMaxObjectSizeLimit(config.getEffectiveMaxObjectSizeLimit(
-        projectControl.getProjectState()));
-    init(rp);
-    rp.setPostReceiveHook(PostReceiveHookChain.newChain(
-        Lists.newArrayList(postReceiveHooks)));
+    ReceivePack rp = receive.getReceivePack();
     try {
       rp.receive(in, out, err);
+      session.setPeerAgent(rp.getPeerUserAgent());
     } catch (UnpackException badStream) {
       // In case this was caused by the user pushing an object whose size
       // is larger than the receive.maxObjectSizeLimit gerrit.config parameter
@@ -177,12 +160,6 @@
     }
   }
 
-  private void init(ReceivePack rp) {
-    for (ReceivePackInitializer initializer : receivePackInitializers) {
-      initializer.init(projectControl.getProject().getNameKey(), rp);
-    }
-  }
-
   private void verifyProjectVisible(final String type, final Set<Account.Id> who)
       throws UnloggedFailure {
     for (final Account.Id id : who) {
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 e9043f7..db32b3f 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
@@ -18,9 +18,8 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 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.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
@@ -28,14 +27,10 @@
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -47,7 +42,9 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 @CommandMetaData(name = "set-reviewers", description = "Add or remove reviewers on a change")
@@ -69,7 +66,7 @@
   @Argument(index = 0, required = true, multiValued = true, metaVar = "COMMIT", usage = "changes to modify")
   void addChange(String token) {
     try {
-      changes.addAll(parseChangeId(token));
+      addChangeImpl(token);
     } catch (UnloggedFailure e) {
       throw new IllegalArgumentException(e.getMessage(), e);
     } catch (OrmException e) {
@@ -81,9 +78,6 @@
   private ReviewDb db;
 
   @Inject
-  private Provider<InternalChangeQuery> queryProvider;
-
-  @Inject
   private ReviewerResource.Factory reviewerFactory;
 
   @Inject
@@ -96,24 +90,25 @@
   private Provider<CurrentUser> userProvider;
 
   @Inject
-  private ChangeControl.GenericFactory changeControlFactory;
-
-  @Inject
   private ChangesCollection changesCollection;
 
+  @Inject
+  private ChangeUtil changeUtil;
+
   private Set<Account.Id> toRemove = new HashSet<>();
-  private Set<Change.Id> changes = new HashSet<>();
+
+  private Map<Change.Id, ChangeResource> changes = new LinkedHashMap<>();
 
   @Override
   protected void run() throws UnloggedFailure {
     boolean ok = true;
-    for (Change.Id changeId : changes) {
+    for (ChangeResource rsrc : changes.values()) {
       try {
-        ok &= modifyOne(changeId);
+        ok &= modifyOne(rsrc);
       } catch (Exception err) {
         ok = false;
-        log.error("Error updating reviewers on change " + changeId, err);
-        writeError("fatal", "internal error while updating " + changeId);
+        log.error("Error updating reviewers on change " + rsrc.getId(), err);
+        writeError("fatal", "internal error while updating " + rsrc.getId());
       }
     }
 
@@ -122,8 +117,7 @@
     }
   }
 
-  private boolean modifyOne(Change.Id changeId) throws Exception {
-    ChangeResource changeRsrc = changesCollection.parse(changeId);
+  private boolean modifyOne(ChangeResource changeRsrc) throws Exception {
     boolean ok = true;
 
     // Remove reviewers
@@ -168,92 +162,28 @@
     return ok;
   }
 
-  private Set<Change.Id> parseChangeId(String idstr)
-      throws UnloggedFailure, OrmException {
-    Set<Change.Id> matched = new HashSet<>(4);
-    boolean isCommit = idstr.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$");
-
-    // By newer style changeKey?
-    //
-    boolean changeKeyParses = idstr.matches("^I[0-9a-f]*$");
-    if (changeKeyParses) {
-      for (ChangeData cd : queryProvider.get().byKeyPrefix(idstr)) {
-        matchChange(matched, cd.change());
+  private void addChangeImpl(String id) throws UnloggedFailure, OrmException {
+    List<ChangeControl> matched =
+        changeUtil.findChanges(id, userProvider.get());
+    List<ChangeControl> toAdd = new ArrayList<>(changes.size());
+    for (ChangeControl ctl : matched) {
+      Change c = ctl.getChange();
+      if (!changes.containsKey(c.getId()) && inProject(c)
+          && ctl.isVisible(db)) {
+        toAdd.add(ctl);
       }
     }
-
-    // By commit?
-    //
-    if (isCommit) {
-      RevId id = new RevId(idstr);
-      ResultSet<PatchSet> patches;
-      if (id.isComplete()) {
-        patches = db.patchSets().byRevision(id);
-      } else {
-        patches = db.patchSets().byRevisionRange(id, id.max());
-      }
-
-      for (PatchSet ps : patches) {
-        matchChange(matched, ps.getId().getParentKey());
-      }
-    }
-
-    // By older style changeId?
-    //
-    boolean changeIdParses = false;
-    if (idstr.matches("^[1-9][0-9]*$")) {
-      Change.Id id;
-      try {
-        id = Change.Id.parse(idstr);
-        changeIdParses = true;
-      } catch (IllegalArgumentException e) {
-        id = null;
-        changeIdParses = false;
-      }
-
-      if (changeIdParses) {
-        matchChange(matched, id);
-      }
-    }
-
-    if (!changeKeyParses && !isCommit && !changeIdParses) {
-      throw error("\"" + idstr + "\" is not a valid change");
-    }
-
-    switch (matched.size()) {
+    switch (toAdd.size()) {
       case 0:
-        throw error("\"" + idstr + "\" no such change");
+        throw error("\"" + id + "\" no such change");
 
       case 1:
-        return matched;
+        ChangeControl ctl = toAdd.get(0);
+        changes.put(ctl.getId(), changesCollection.parse(ctl));
+        break;
 
       default:
-        throw error("\"" + idstr + "\" matches multiple changes");
-    }
-  }
-
-  private void matchChange(Set<Change.Id> matched, Change.Id changeId) {
-    if (changeId != null && !matched.contains(changeId)) {
-      try {
-        matchChange(matched, db.changes().get(changeId));
-      } catch (OrmException e) {
-        log.warn("Error reading change " + changeId, e);
-      }
-    }
-  }
-
-  private void matchChange(Set<Change.Id> matched, Change change) {
-    try {
-      if (change != null
-          && inProject(change)
-          && changeControlFactory.controlFor(change,
-                userProvider.get()).isVisible(db)) {
-        matched.add(change.getId());
-      }
-    } catch (NoSuchChangeException e) {
-      // Ignore this change.
-    } catch (OrmException e) {
-      log.warn("Error reading change " + change.getId(), e);
+        throw error("\"" + id + "\" matches multiple changes");
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
index 34f7107..d278f4b 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.git.ChangeCache;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.UploadPackMetricsHook;
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.git.validators.UploadValidationException;
 import com.google.gerrit.server.git.validators.UploadValidators;
@@ -58,6 +59,9 @@
   @Inject
   private SshSession session;
 
+  @Inject
+  private UploadPackMetricsHook uploadMetrics;
+
   @Override
   protected void runImpl() throws IOException, Failure {
     if (!projectControl.canRunUploadPack()) {
@@ -71,6 +75,7 @@
     }
     up.setPackConfig(config.getPackConfig());
     up.setTimeout(config.getTimeout());
+    up.setPostUploadHook(uploadMetrics);
 
     List<PreUploadHook> allPreUploadHooks = Lists.newArrayList(preUploadHooks);
     allPreUploadHooks.add(uploadValidatorsFactory.create(project, repo,
@@ -78,6 +83,7 @@
     up.setPreUploadHook(PreUploadHookChain.newChain(allPreUploadHooks));
     try {
       up.upload(in, out, err);
+      session.setPeerAgent(up.getPeerUserAgent());
     } catch (UploadValidationException e) {
       // UploadValidationException is used by the UploadValidators to
       // stop the uploadPack. We do not want this exception to go beyond this
diff --git a/gerrit-war/pom.xml b/gerrit-war/pom.xml
index dab19a5..f0430db 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.12</version>
+  <version>2.13-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 5e36318..a2947af 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
@@ -23,9 +23,11 @@
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
+import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
+import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
@@ -260,6 +262,7 @@
       });
     }
     modules.add(new DatabaseModule());
+    modules.add(new DropWizardMetricMaker.ApiModule());
     return Guice.createInjector(PRODUCTION, modules);
   }
 
@@ -288,6 +291,7 @@
 
   private Injector createSysInjector() {
     final List<Module> modules = new ArrayList<>();
+    modules.add(new DropWizardMetricMaker.RestModule());
     modules.add(new WorkQueue.Module());
     modules.add(new ChangeHookRunner.Module());
     modules.add(new ReceiveCommitsExecutorModule());
@@ -319,7 +323,8 @@
     modules.add(new AbstractModule() {
       @Override
       protected void configure() {
-        bind(GerritOptions.class).toInstance(new GerritOptions(false, false));
+        bind(GerritOptions.class)
+            .toInstance(new GerritOptions(config, false, false, false));
       }
     });
     modules.add(new GarbageCollectionModule());
@@ -347,8 +352,11 @@
     final List<Module> modules = new ArrayList<>();
     modules.add(RequestContextFilter.module());
     modules.add(AllRequestFilter.module());
+    modules.add(RequestMetricsFilter.module());
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
     modules.add(sysInjector.getInstance(WebModule.class));
+    modules.add(sysInjector.getInstance(StaticModule.class));
+    modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
     if (sshInjector != null) {
       modules.add(sshInjector.getInstance(WebSshGlueModule.class));
     } else {
diff --git a/lib/BUCK b/lib/BUCK
index c635bbb..7d19f27 100644
--- a/lib/BUCK
+++ b/lib/BUCK
@@ -1,14 +1,12 @@
 include_defs('//lib/maven.defs')
 
+define_license(name = 'antlr')
 define_license(name = 'Apache1.1')
 define_license(name = 'Apache2.0')
-define_license(name = 'CC-BY3.0')
-define_license(name = 'MPL1.1')
-define_license(name = 'PublicDomain')
-define_license(name = 'antlr')
 define_license(name = 'args4j')
 define_license(name = 'automaton')
 define_license(name = 'bouncycastle')
+define_license(name = 'CC-BY3.0')
 define_license(name = 'clippy')
 define_license(name = 'codemirror')
 define_license(name = 'diffy')
@@ -17,12 +15,18 @@
 define_license(name = 'h2')
 define_license(name = 'jgit')
 define_license(name = 'jsch')
+define_license(name = 'MPL1.1')
 define_license(name = 'ow2')
+define_license(name = 'page.js')
+define_license(name = 'polymer')
 define_license(name = 'postgresql')
 define_license(name = 'prologcafe')
+define_license(name = 'promise-polyfill')
 define_license(name = 'protobuf')
+define_license(name = 'PublicDomain')
 define_license(name = 'slf4j')
 define_license(name = 'xz')
+
 define_license(name = 'DO_NOT_DISTRIBUTE')
 
 maven_jar(
diff --git a/lib/LICENSE-page.js b/lib/LICENSE-page.js
new file mode 100644
index 0000000..78152a9
--- /dev/null
+++ b/lib/LICENSE-page.js
@@ -0,0 +1,20 @@
+(The MIT License)
+
+Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+
+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, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+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. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/LICENSE-polymer b/lib/LICENSE-polymer
new file mode 100644
index 0000000..322c5a8
--- /dev/null
+++ b/lib/LICENSE-polymer
@@ -0,0 +1,27 @@
+Copyright (c) 2014 The Polymer Authors. 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.
diff --git a/lib/LICENSE-promise-polyfill b/lib/LICENSE-promise-polyfill
new file mode 100644
index 0000000..6f7c0123
--- /dev/null
+++ b/lib/LICENSE-promise-polyfill
@@ -0,0 +1,20 @@
+Copyright (c) 2014 Taylor Hakes
+Copyright (c) 2014 Forbes Lindesay
+
+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, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+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. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/lib/dropwizard/BUCK b/lib/dropwizard/BUCK
new file mode 100644
index 0000000..de73e13
--- /dev/null
+++ b/lib/dropwizard/BUCK
@@ -0,0 +1,8 @@
+include_defs('//lib/maven.defs')
+
+maven_jar(
+  name = 'dropwizard-core',
+  id = 'io.dropwizard.metrics:metrics-core:3.1.2',
+  sha1 = '224f03afd2521c6c94632f566beb1bb5ee32cf07',
+  license = 'Apache2.0',
+)
diff --git a/lib/js.defs b/lib/js.defs
new file mode 100644
index 0000000..9508c30
--- /dev/null
+++ b/lib/js.defs
@@ -0,0 +1,169 @@
+# 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.
+
+NPMJS = 'NPMJS'
+GERRIT = 'GERRIT'
+
+# NOTE: npm_binary rules do not get their licenses checked by gen_licenses.py,
+# as we would have to cut too many edges. DO NOT include these binaries in
+# build outputs. Using them in the build _process_ is ok.
+def npm_binary(
+    name,
+    version,
+    sha1 = '',
+    repository = NPMJS,
+    visibility = ['PUBLIC']):
+
+  dir = '%s-%s' % (name, version)
+  filename = '%s.tgz' % dir
+  dest = '%s@%s.npm_binary.tgz' % (name, version)
+  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:
+    raise ValueError('invalid repository: %s' % repository)
+  cmd = ['$(exe //tools:download_file)', '-o', '$OUT', '-u', url]
+  if sha1:
+    cmd.extend(['-v', sha1])
+  genrule(
+    name = name,
+    cmd = ' '.join(cmd),
+    out = dest,
+    visibility = visibility,
+  )
+
+
+def run_npm_binary(target):
+  return '$(location //tools/js:run_npm_binary) $(location %s)' % target
+
+
+def bower_component(
+    name,
+    package,
+    version,
+    license,
+    deps = [],
+    semver = None,
+    sha1 = '',
+    visibility = ['PUBLIC']):
+  download_name = '%s__download_bower' % name
+  genrule(
+    name = download_name,
+    cmd = ' '.join([
+      '$(exe //tools/js:download_bower)',
+      '-b', '"%s"' % run_npm_binary('//lib/js:bower'),
+      '-n', name,
+      '-p', package,
+      '-v', version,
+      '-s', sha1,
+      '-o', '$OUT',
+    ]),
+    out = '%s.zip' % download_name,
+    license = license,
+    visibility = [],
+  )
+
+  renamed_name = '%s__renamed' % name
+  genrule(
+    name = renamed_name,
+    cmd = ' && '.join([
+      'cd $TMP',
+      'mkdir bower_components',
+      'cd bower_components',
+      'unzip $(location :%s)' % download_name,
+      'cd ..',
+      'zip -r $OUT bower_components',
+    ]),
+    out = '%s.zip' % renamed_name,
+    visibility = [],
+  )
+
+  genrule(
+    name = name,
+    cmd = _combine_components([':%s' % renamed_name] + deps),
+    out = '%s-%s.zip' % (name, version),
+    visibility = visibility,
+  )
+
+  version_name = '%s__bower_version' % name
+  dep_version = semver if semver is not None else version
+  deps_json = '{"%s": "%s#%s"}' % (name, package, dep_version)
+  genrule(
+    name = version_name,
+    cmd = "echo '%s' > $OUT" % deps_json,
+    out = version_name,
+    visibility = visibility,
+  )
+
+
+def bower_components(
+    name,
+    deps,
+    visibility = ['PUBLIC']):
+  genrule(
+    name = name,
+    cmd = _combine_components(deps),
+    out = '%s.bower_components.zip' % name,
+    visibility = visibility,
+  )
+
+
+def _combine_components(deps):
+  cmds = ['cd $TMP']
+  for d in deps:
+    cmds.append('unzip -qo $(location %s)' % d)
+  cmds.append('zip -r $OUT bower_components')
+  return ' && '.join(cmds)
+
+
+VULCANIZE_FLAGS = [
+  '--inline-scripts',
+  '--inline-css',
+  '--strip-comments',
+]
+
+def vulcanize(
+    name,
+    app,
+    srcs,
+    components,
+    extra_flags = [],
+    visibility = ['PUBLIC']):
+  genrule(
+    name = '%s__vulcanized' % name,
+    cmd = ' '.join([
+      'unzip', '-qd', '$SRCDIR', '$(location %s)' % components,
+      '&&', run_npm_binary('//lib/js:vulcanize')
+    ] + VULCANIZE_FLAGS + extra_flags + [
+      '--out-html', '$OUT',
+      '$SRCDIR/%s' % app,
+    ]),
+    srcs = srcs,
+    out = '%s.vulcanized.html' % name,
+    visibility = visibility,
+  )
+
+  genrule(
+    name = name,
+    cmd = ' '.join([
+      'cd', '$TMP',
+      '&&', run_npm_binary('//lib/js:crisper'), '--always-write-script',
+      '--source', '$(location :%s__vulcanized)' % name,
+      '--html', '%s.html' % name,
+      '--js', '%s.js' % name,
+      '&&', 'zip', '$OUT', '%s.html' % name, '%s.js' % name,
+    ]),
+    out = '%s.vulcanized.zip',
+  )
diff --git a/lib/js/BUCK b/lib/js/BUCK
new file mode 100644
index 0000000..060788c
--- /dev/null
+++ b/lib/js/BUCK
@@ -0,0 +1,328 @@
+include_defs('//lib/js.defs')
+
+# WHEN REVIEWING NEW NPM_BINARY RULES:
+#
+# You must check licenses in the transitive closure of dependencies to ensure
+# they can be used by Gerrit. (npm binaries are not distributed with Gerrit
+# releases, so we are less restrictive in our selection of licenses, but we
+# still need to do a sanity check.)
+#
+# To do this:
+#   npm install -g license-checker
+#   mkdir /tmp/npmtmp
+#   cd /tmp/npmtmp
+#   npm install <package>@<version>
+#   license-checker
+# (Piping to grep -o 'licenses:.*' and/or sort -u may make the output saner.)
+
+npm_binary(
+  name = 'bower',
+  version = '1.6.5',
+  sha1 = '59d457122a161e42cc1625bbab8179c214b7ac11',
+)
+
+npm_binary(
+  name = 'crisper',
+  version = '2.0.1',
+  sha1 = 'b3b8bacc1f6d119af26664b8620e6a978aa7f7d3',
+  repository = GERRIT,
+)
+
+npm_binary(
+  name = 'vulcanize',
+  version = '1.14.0',
+  sha1 = '91eac280d031b5bbcafb5f86bb6ed30515fa2564',
+  repository = GERRIT,
+)
+
+# ## Adding Bower component dependencies
+#
+# 1. Add a dummy bower_component rule to this file, specifying the semantic
+#    version you want to use. The actual version will be filled in by Bower,
+#    after evaluating the full dependency tree.
+#
+#      bower_component(
+#        name = 'somepackage',
+#        package = 'someauthor/somepackage',
+#        version = 'TODO',
+#        semver = '~1.0.0',
+#        license = 'DO_NOT_DISTRIBUTE'
+#      )
+#
+# 2. Add your bower_component as a dep to a bower_components rule.
+#
+#      bower_components(
+#        name = 'polygerrit_components',
+#        deps = [
+#          '//lib/js:foo',
+#          '//lib/js:somepackage',  # NEW
+#        ],
+#      )
+#
+# 3. Run bower2buck.py.
+#
+#      buck run //tools/js:bower2buck -- -o /tmp/newbuck
+#
+# 4. Use your favorite diff tool to merge the output in newbuck with this file.
+#    bower2buck reevaluates semantic versions and may upgrade some packages, so
+#    you may need to make changes beyond the new component that was added.
+#
+#      meld /tmp/newbuck lib/js/BUCK
+#
+#
+# ## Updating Bower component dependencies
+#
+# Use the same procedure as for adding dependencies, except just change the
+# version number of the existing bower_component rather than adding a new rule.
+
+bower_component(
+  name = 'font-roboto',
+  package = 'polymerelements/font-roboto',
+  version = '1.0.1',
+  license = 'polymer',
+  sha1 = '735676217f67221903d6be10cc2fb1b336bed13f',
+)
+
+bower_component(
+  name = 'iron-a11y-keys-behavior',
+  package = 'polymerelements/iron-a11y-keys-behavior',
+  version = '1.1.0',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = '0b7962ed8409336652da4b4e83d052dbe53d4e1a',
+)
+
+bower_component(
+  name = 'iron-ajax',
+  package = 'polymerelements/iron-ajax',
+  version = '1.1.0',
+  deps = [
+    ':polymer',
+    ':promise-polyfill',
+  ],
+  license = 'polymer',
+  sha1 = 'f94a3a3d847842c49def41e27da42c7c94f8d7c7',
+)
+
+bower_component(
+  name = 'iron-autogrow-textarea',
+  package = 'polymerelements/iron-autogrow-textarea',
+  version = '1.0.10',
+  deps = [
+    ':iron-behaviors',
+    ':iron-flex-layout',
+    ':iron-form-element-behavior',
+    ':iron-validatable-behavior',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = 'd368240e60a4b02ffc731ad8f45f3c8bbf47e9bd',
+)
+
+bower_component(
+  name = 'iron-behaviors',
+  package = 'polymerelements/iron-behaviors',
+  version = '1.0.11',
+  deps = [
+    ':iron-a11y-keys-behavior',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = 'e0fcfcd8696381fc78ff62261ba333e5e133f39d',
+)
+
+bower_component(
+  name = 'iron-dropdown',
+  package = 'polymerelements/iron-dropdown',
+  version = '1.0.6',
+  deps = [
+    ':iron-a11y-keys-behavior',
+    ':iron-behaviors',
+    ':iron-overlay-behavior',
+    ':iron-resizable-behavior',
+    ':neon-animation',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = 'b54ff404ce5535919979bb4488e4b6ae9146fc5a',
+)
+
+bower_component(
+  name = 'iron-fit-behavior',
+  package = 'polymerelements/iron-fit-behavior',
+  version = '1.0.5',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = 'c0273d22531451a1e64f447971ad16b357a7f7e0',
+)
+
+bower_component(
+  name = 'iron-flex-layout',
+  package = 'polymerelements/iron-flex-layout',
+  version = '1.2.2',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = '3ca2fbbf3b56d95677663f78304262dee68753c3',
+)
+
+bower_component(
+  name = 'iron-form-element-behavior',
+  package = 'polymerelements/iron-form-element-behavior',
+  version = '1.0.6',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = '8d9e6530edc1b99bec1a5c34853911fba3701220',
+)
+
+bower_component(
+  name = 'iron-input',
+  package = 'polymerelements/iron-input',
+  version = '1.0.6',
+  deps = [
+    ':iron-validatable-behavior',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = '2d3eedf0a26046c0e828b1ce3d5b102ee1d0ab19',
+)
+
+bower_component(
+  name = 'iron-meta',
+  package = 'polymerelements/iron-meta',
+  version = '1.1.1',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = 'e06281b6ddb3355ceca44975a167381b1fd72ce5',
+)
+
+bower_component(
+  name = 'iron-overlay-behavior',
+  package = 'polymerelements/iron-overlay-behavior',
+  version = '1.1.1',
+  deps = [
+    ':iron-fit-behavior',
+    ':iron-resizable-behavior',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = '98d80ea1cbee2631553d4fbc98da6cbb25748a4f',
+)
+
+bower_component(
+  name = 'iron-resizable-behavior',
+  package = 'polymerelements/iron-resizable-behavior',
+  version = '1.0.2',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = '954e82c70b5412d20e7b4d65195a844bb6dc9a07',
+)
+
+bower_component(
+  name = 'iron-selector',
+  package = 'polymerelements/iron-selector',
+  version = '1.0.8',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = '7559560733882656bf479b620669a1d60c3bda21',
+)
+
+bower_component(
+  name = 'iron-test-helpers',
+  package = 'polymerelements/iron-test-helpers',
+  version = '1.0.6',
+  deps = [':polymer'],
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = 'c0f7c7f010ca3c63fb08ae0d9462e400380cde2c',
+)
+
+bower_component(
+  name = 'iron-validatable-behavior',
+  package = 'polymerelements/iron-validatable-behavior',
+  version = '1.0.5',
+  deps = [
+    ':iron-meta',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = '5a68250d6d9abcd576f116dc4fc7312426323883',
+)
+
+bower_component(
+  name = 'neon-animation',
+  package = 'polymerelements/neon-animation',
+  version = '1.0.8',
+  deps = [
+    ':iron-meta',
+    ':iron-resizable-behavior',
+    ':iron-selector',
+    ':paper-styles',
+    ':polymer',
+    ':web-animations-js',
+  ],
+  license = 'polymer',
+  sha1 = 'c5f3700e9259554db14f9dfddb290a42c099d88a',
+)
+
+bower_component(
+  name = 'page',
+  package = 'visionmedia/page.js',
+  version = '1.6.4',
+  license = 'page.js',
+  sha1 = 'cc442386d4e392be26c85873f463db76fafbaeaf',
+)
+
+bower_component(
+  name = 'paper-styles',
+  package = 'polymerelements/paper-styles',
+  version = '1.0.13',
+  deps = [
+    ':font-roboto',
+    ':iron-flex-layout',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = 'e0bfdadfe10e070f39c16aa784de16734eed25a6',
+)
+
+bower_component(
+  name = 'polymer',
+  package = 'polymer/polymer',
+  version = '1.2.2',
+  deps = [':webcomponentsjs'],
+  license = 'polymer',
+  sha1 = '7f4033438425584d8912a80614d1a4f754438e15',
+)
+
+bower_component(
+  name = 'promise-polyfill',
+  package = 'polymerlabs/promise-polyfill',
+  version = '1.0.0',
+  deps = [':polymer'],
+  license = 'promise-polyfill',
+  sha1 = 'a3b598c06cbd7f441402e666ff748326030905d6',
+)
+
+bower_component(
+  name = 'test-fixture',
+  package = 'polymerelements/test-fixture',
+  version = '1.0.3',
+  semver = '^1.0.0',
+  license = 'DO_NOT_DISTRIBUTE',
+  sha1 = '21192d554ff6ad7eea894ca751c73b6bc46867dc',
+)
+
+bower_component(
+  name = 'web-animations-js',
+  package = 'web-animations/web-animations-js',
+  version = '2.1.2',
+  license = 'Apache2.0',
+  sha1 = '3e2f4648b770183f577cb5171785cfedcb3a960b',
+)
+
+bower_component(
+  name = 'webcomponentsjs',
+  package = 'webcomponentsjs',
+  version = '0.7.17',
+  license = 'polymer',
+  sha1 = '36e29cfe21caa71322a0b5026d7d423c33c0426f',
+)
diff --git a/lib/lucene/BUCK b/lib/lucene/BUCK
index c5107d5..7d0a16c 100644
--- a/lib/lucene/BUCK
+++ b/lib/lucene/BUCK
@@ -1,6 +1,6 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '5.3.0'
+VERSION = '5.3.1'
 
 # core and backward-codecs both provide
 # META-INF/services/org.apache.lucene.codecs.Codec, so they must be merged.
@@ -16,7 +16,7 @@
 maven_jar(
   name = 'core_jar',
   id = 'org.apache.lucene:lucene-core:' + VERSION,
-  sha1 = '9e12bb7c39e964a544e3a23b9c8ffa9599d38f10',
+  sha1 = '36860653d7e09790ada96aeb1970b4ca396ac5d7',
   license = 'Apache2.0',
   exclude = [
     'META-INF/LICENSE.txt',
@@ -28,7 +28,7 @@
 maven_jar(
   name = 'analyzers-common',
   id = 'org.apache.lucene:lucene-analyzers-common:' + VERSION,
-  sha1 = '1502beac94cf437baff848ffbbb8f76172befa6b',
+  sha1 = 'bd804dbc1b8f7941018926e940d20d1016b36c4c',
   license = 'Apache2.0',
   deps = [':core-and-backward-codecs'],
   exclude = [
@@ -40,7 +40,7 @@
 maven_jar(
   name = 'backward-codecs_jar',
   id = 'org.apache.lucene:lucene-backward-codecs:' + VERSION,
-  sha1 = 'f654901e55fe56bdbe4be202767296929c2f8d9e',
+  sha1 = '380603f537317a78f9d9b7421bc2ac87586cb9a1',
   license = 'Apache2.0',
   deps = [':core_jar'],
   exclude = [
@@ -53,7 +53,7 @@
 maven_jar(
   name = 'misc',
   id = 'org.apache.lucene:lucene-misc:' + VERSION,
-  sha1 = 'd03ce6d1bb8ab3926b3acc717418c474a49ade69',
+  sha1 = '7891bbc18b372135c2a52b471075b0bdf5f110ec',
   license = 'Apache2.0',
   deps = [':core-and-backward-codecs'],
   exclude = [
@@ -65,7 +65,7 @@
 maven_jar(
   name = 'queryparser',
   id = 'org.apache.lucene:lucene-queryparser:' + VERSION,
-  sha1 = '2c5e08580316c90b56a52e3cb686e1cf69db3f9e',
+  sha1 = 'bef0e2ac5b196dbab9d0b7c8cc8196b7ef5dd056',
   license = 'Apache2.0',
   deps = [':core-and-backward-codecs'],
   exclude = [
diff --git a/plugins/README b/plugins/README
deleted file mode 100644
index 00df3c5..0000000
--- a/plugins/README
+++ /dev/null
@@ -1,11 +0,0 @@
-If you are adding a directory here:
-
-- Search all pom.xml files for "CORE PLUGIN LIST".
-- Add the new plugin to that location.
-- (optional) Thank the Maven developers for making this easy.
-
-- Ensure the plugin's pom.xml <version> is the same as Gerrit's
-  own pom.xml(s). Gerrit will only embed a plugin that has the
-  same version as itself.
-
-- Register the plugin as a submodule with git submodule.
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
index 82eefc2..eea84e7 160000
--- a/plugins/cookbook-plugin
+++ b/plugins/cookbook-plugin
@@ -1 +1 @@
-Subproject commit 82eefc2048a4dd69ab589213190dd8403295fb7d
+Subproject commit eea84e7e07ecf6ebb70ea5a6b0cde67f5a5576af
diff --git a/plugins/replication b/plugins/replication
index 675d9dd..4ab29b7 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 675d9dd370948f0508dbe536dfc65cc6cd3bb00d
+Subproject commit 4ab29b755a147a69d88bf000e276a7a2eaa6403b
diff --git a/polygerrit-ui/.gitattributes b/polygerrit-ui/.gitattributes
new file mode 100644
index 0000000..2125666
--- /dev/null
+++ b/polygerrit-ui/.gitattributes
@@ -0,0 +1 @@
+* text=auto
\ No newline at end of file
diff --git a/polygerrit-ui/.gitignore b/polygerrit-ui/.gitignore
new file mode 100644
index 0000000..73b5221
--- /dev/null
+++ b/polygerrit-ui/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+npm-debug.log
+dist
+bower.json
+bower_components
+.tmp
diff --git a/polygerrit-ui/BUCK b/polygerrit-ui/BUCK
new file mode 100644
index 0000000..4c055f4
--- /dev/null
+++ b/polygerrit-ui/BUCK
@@ -0,0 +1,15 @@
+include_defs('//lib/js.defs')
+
+bower_components(
+  name = 'polygerrit_components',
+  deps = [
+    '//lib/js:iron-a11y-keys-behavior',
+    '//lib/js:iron-ajax',
+    '//lib/js:iron-autogrow-textarea',
+    '//lib/js:iron-dropdown',
+    '//lib/js:iron-input',
+    '//lib/js:iron-selector',
+    '//lib/js:page',
+    '//lib/js:polymer',
+  ],
+)
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
new file mode 100644
index 0000000..cbc2660
--- /dev/null
+++ b/polygerrit-ui/README.md
@@ -0,0 +1,67 @@
+# PolyGerrit
+
+## Installing [Node.js](https://nodejs.org/en/download/)
+
+```sh
+# Debian/Ubuntu
+sudo apt-get install nodejs-legacy
+
+# OS X with Homebrew
+brew install node
+```
+
+All other platforms: [download from
+nodejs.org](https://nodejs.org/en/download/).
+
+## Local UI, Production Data
+
+To test the local UI against gerrit-review.googlesource.com:
+
+```sh
+./run-server.sh
+```
+
+Then visit http://localhost:8081
+
+## Local UI, Test Data
+
+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](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
+
+Run a test server:
+
+```sh
+buck build polygerrit && \
+java -jar buck-out/gen/polygerrit/polygerrit.war daemon --polygerrit-dev -d ../gerrit_testsite --console-log --show-stack-trace
+```
+
+## Running Tests
+
+One-time setup:
+
+```sh
+# Debian/Ubuntu
+sudo apt-get install npm
+
+# OS X with Homebrew
+brew install npm
+
+# All platforms (including those above)
+sudo npm install -g web-component-tester
+```
+
+Run all web tests:
+
+```sh
+buck test --include web
+```
+
+If you need to pass additional arguments to `wct`:
+
+```sh
+WCT_ARGS='-p --some-flag="foo bar"' buck test --no-results-cache --include web
+```
diff --git a/polygerrit-ui/app/BUCK b/polygerrit-ui/app/BUCK
new file mode 100644
index 0000000..93cf614
--- /dev/null
+++ b/polygerrit-ui/app/BUCK
@@ -0,0 +1,77 @@
+include_defs('//lib/js.defs')
+
+WCT_TEST_PATTERNS = ['test/**']
+PY_TEST_PATTERNS = ['polygerrit_wct_tests.py']
+APP_SRCS = glob(
+  ['**'],
+  excludes = [
+    'BUCK',
+    'index.html',
+  ] + WCT_TEST_PATTERNS + PY_TEST_PATTERNS)
+
+WEBJS = 'bower_components/webcomponentsjs/webcomponents-lite.js'
+
+# TODO(dborowitz): Putting these rules in this package avoids having to handle
+# the app/ prefix like we would have to if this were in the parent directory.
+# The only reason for the app subdirectory in the first place was convenience
+# when witing server.go; when that goes away, we can just move all the files and
+# these rules up one directory.
+genrule(
+  name = 'polygerrit_ui',
+  cmd = ' && '.join([
+    'mkdir $TMP/polygerrit_ui',
+    'cd $TMP/polygerrit_ui',
+    'mkdir -p {elements,bower_components/webcomponentsjs}',
+    'unzip -qd elements $(location :gr-app)',
+    'cp -rp $SRCDIR/* .',
+    'unzip -p $(location //polygerrit-ui:polygerrit_components) %s>%s' % (WEBJS, WEBJS),
+    'cd $TMP',
+    'zip -9qr $OUT .',
+  ]),
+  srcs = glob([
+    'favicon.ico',
+    'index.html',
+    'styles/**/*.css'
+  ]),
+  out = 'polygerrit_ui.zip',
+  visibility = ['PUBLIC'],
+)
+
+vulcanize(
+  name = 'gr-app',
+  app = 'elements/gr-app.html',
+  srcs = APP_SRCS,
+  components = '//polygerrit-ui:polygerrit_components',
+)
+
+
+bower_components(
+  name = 'test_components',
+  deps = [
+    '//polygerrit-ui:polygerrit_components',
+    '//lib/js:iron-test-helpers',
+    '//lib/js:test-fixture',
+  ],
+)
+
+genrule(
+  name = 'test_resources',
+  cmd = ' && '.join([
+    'cd $TMP',
+    'unzip -q $(location :test_components)',
+    'cp -r $SRCDIR/* .',
+    'zip -r $OUT .',
+  ]),
+  srcs = APP_SRCS + glob(WCT_TEST_PATTERNS),
+  out = 'test_resources.zip',
+)
+
+python_test(
+  name = 'polygerrit_tests',
+  srcs = glob(PY_TEST_PATTERNS),
+  resources = [':test_resources'],
+  labels = [
+    'manual',
+    'web',
+  ],
+)
diff --git a/polygerrit-ui/app/elements/gr-account-dropdown.html b/polygerrit-ui/app/elements/gr-account-dropdown.html
new file mode 100644
index 0000000..887cb0c
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-account-dropdown.html
@@ -0,0 +1,98 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../bower_components/iron-dropdown/iron-dropdown.html">
+
+<dom-module id="gr-account-dropdown">
+  <style>
+    :host {
+      display: inline-block;
+    }
+    .dropdown-trigger {
+      color: #00e;
+      cursor: pointer;
+    }
+    .dropdown-content {
+      background-color: #fff;
+      box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+    }
+    button {
+      background: none;
+      border: none;
+      font: inherit;
+      padding: .3em 0;
+    }
+    ul {
+      list-style: none;
+    }
+    ul .accountName {
+      font-weight: bold;
+    }
+    li .accountInfo,
+    li a {
+      display: block;
+      padding: .85em 1em;
+    }
+    li a:link,
+    li a:visited {
+      color: #00e;
+      text-decoration: none;
+    }
+    li a:hover {
+      background-color: #6B82D6;
+      color: #fff;
+    }
+  </style>
+  <template>
+    <button class="dropdown-trigger" id="trigger"
+        on-tap="_showDropdownTapHandler">[[account.name]]</button>
+    <iron-dropdown id="dropdown"
+        vertical-align="top"
+        vertical-offset="25"
+        horizontal-align="right">
+      <div class="dropdown-content">
+        <ul>
+          <li>
+            <div class="accountInfo">
+              <div class="accountName">[[account.name]]</div>
+              <div>[[account.email]]</div>
+            </div>
+          </li>
+          <li><a href="/switch-account">Switch account</a></li>
+          <li><a href="/logout">Logout</a></li>
+        </ul>
+      </div>
+    </iron-dropdown>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-account-dropdown',
+
+      properties: {
+        account: Object,
+      },
+
+      _showDropdownTapHandler: function(e) {
+        this.$.dropdown.open();
+      },
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-ajax.html b/polygerrit-ui/app/elements/gr-ajax.html
new file mode 100644
index 0000000..9671b37
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-ajax.html
@@ -0,0 +1,97 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../bower_components/iron-ajax/iron-ajax.html">
+
+<dom-module id="gr-ajax">
+  <template>
+    <iron-ajax id="xhr"
+        auto="[[auto]]"
+        url="[[url]]"
+        params="[[params]]"
+        json-prefix=")]}'"
+        last-response="{{lastResponse}}"
+        loading="{{loading}}"
+        on-response="_handleResponse"
+        on-error="_handleError"
+        debounce-duration="300"></iron-ajax>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-ajax',
+
+      /**
+       * Fired when a response is received.
+       * This event does not have a gr- prefix in order to maintain a similar
+       * API to iron-ajax.
+       *
+       * @event response
+       */
+
+      /**
+       * Fired when an error is received.
+       * This event does not have a gr- prefix in order to maintain a similar
+       * API to iron-ajax.
+       *
+       * @event error
+       */
+
+      hostAttributes: {
+        hidden: true
+      },
+
+      properties: {
+        auto: {
+          type: Boolean,
+          value: false,
+        },
+        url: String,
+        params: {
+          type: Object,
+          value: function() {
+            return {};
+          },
+        },
+        lastResponse: {
+          type: Object,
+          notify: true,
+        },
+        loading: {
+          type: Boolean,
+          notify: true,
+        },
+      },
+
+      generateRequest: function() {
+        return this.$.xhr.generateRequest();
+      },
+
+      _handleResponse: function(e, req) {
+        this.fire('response', req, {bubbles: false});
+      },
+
+      _handleError: function(e, req) {
+        this.fire('error', req, {bubbles: false});
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
new file mode 100644
index 0000000..14786d8
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -0,0 +1,194 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../styles/app-theme.html">
+<link rel="import" href="gr-account-dropdown.html">
+<link rel="import" href="gr-ajax.html">
+<link rel="import" href="gr-change-list-view.html">
+<link rel="import" href="gr-change-view.html">
+<link rel="import" href="gr-dashboard-view.html">
+<link rel="import" href="gr-diff-view.html">
+<link rel="import" href="gr-search-bar.html">
+
+<script src="../bower_components/page/page.js"></script>
+<script src="../scripts/app.js"></script>
+<script src="../scripts/changes.js"></script>
+<script src="../scripts/util.js"></script>
+
+<dom-module id="gr-app">
+  <template>
+    <style>
+      :host {
+        background-color: var(--secondary-color);
+        display: flex;
+        min-height: 100vh;
+        flex-direction: column;
+      }
+      :host([constrained]) main {
+        margin: 0 auto;
+        width: 100%;
+        max-width: var(--max-constrained-width);
+      }
+      header,
+      footer {
+        background-color: var(--primary-color);
+        color: var(--primary-text-color);
+        padding: .5rem 1.25rem;
+      }
+      header {
+        display: flex;
+        align-items: center;
+      }
+      main {
+        flex: 1;
+      }
+      .bigTitle {
+        color: var(--primary-text-color);
+        font-size: 1.75em;
+        text-decoration: none;
+      }
+      .bigTitle:hover {
+        text-decoration: underline;
+      }
+      .headerRightItems {
+        display: flex;
+        flex: 1;
+        justify-content: flex-end;
+      }
+      gr-search-bar {
+        width: 500px;
+      }
+      .accountContainer:not(.loggedIn):not(.loggedOut) .loginButton,
+      .accountContainer:not(.loggedIn):not(.loggedOut) gr-account-dropdown,
+      .accountContainer.loggedIn .loginButton,
+      .accountContainer.loggedOut gr-account-dropdown {
+        display: none;
+      }
+      .accountContainer {
+        align-items: center;
+        display: flex;
+        margin-left: var(--default-horizontal-margin);
+      }
+    </style>
+    <gr-ajax auto url="/accounts/self/detail" last-response="{{account}}"></gr-ajax>
+    <gr-ajax auto url="/config/server/info" last-response="{{config}}"></gr-ajax>
+    <header role="banner">
+      <a href="/" class="bigTitle">PolyGerrit</a>
+      <div class="headerRightItems">
+        <gr-search-bar value="{{params.query}}" role="search"></gr-search-bar>
+        <div class="accountContainer" id="accountContainer">
+          <a class="loginButton" href="/login" on-tap="_loginTapHandler">Login</a>
+          <gr-account-dropdown account="[[account]]"></gr-account-dropdown>
+        </div>
+      </div>
+    </header>
+    <main>
+      <template is="dom-if" if="{{_showChangeListView}}" restamp="true">
+        <gr-change-list-view params="[[params]]"></gr-change-list-view>
+      </template>
+      <template is="dom-if" if="{{_showDashboardView}}" restamp="true">
+        <gr-dashboard-view params="[[params]]"></gr-dashboard-view>
+      </template>
+      <template is="dom-if" if="{{_showChangeView}}" restamp="true">
+        <gr-change-view params="[[params]]"></gr-change-view>
+      </template>
+      <template is="dom-if" if="{{_showDiffView}}" restamp="true">
+        <gr-diff-view params="[[params]]"></gr-diff-view>
+      </template>
+    </main>
+    <footer role="contentinfo">Powered by PolyGerrit</footer>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-app',
+
+      properties: {
+        account: {
+          type: Object,
+          observer: '_accountChanged',
+        },
+        accountReady: {
+          type: Object,
+          readOnly: true,
+          notify: true,
+          value: function() {
+            return new Promise(function(resolve) {
+              this._resolveAccountReady = resolve;
+            }.bind(this));
+          },
+        },
+        config: {
+          type: Object,
+          observer: '_configChanged',
+        },
+        configReady: {
+          type: Object,
+          readOnly: true,
+          notify: true,
+          value: function() {
+            return new Promise(function(resolve) {
+              this._resolveConfigReady = resolve;
+            }.bind(this));
+          },
+        },
+        constrained: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        params: Object,
+        route: {
+          type: String,
+          observer: '_routeChanged',
+        },
+      },
+
+      get loggedIn() {
+        return !!(this.account && Object.keys(this.account).length > 0);
+      },
+
+      _accountChanged: function() {
+        this._resolveAccountReady();
+        this.$.accountContainer.classList.toggle('loggedIn', this.loggedIn);
+        this.$.accountContainer.classList.toggle('loggedOut', !this.loggedIn);
+      },
+
+      _configChanged: function(config) {
+        this._resolveConfigReady(config);
+      },
+
+      _routeChanged: function(route) {
+        this.set('_showChangeListView', route == 'gr-change-list-view');
+        this.set('_showDashboardView', route == 'gr-dashboard-view');
+        this.set('_showChangeView', route == 'gr-change-view');
+        this.set('_showDiffView', route == 'gr-diff-view');
+        this.constrained = route == 'gr-change-view';
+      },
+
+      _loginTapHandler: function(e) {
+        e.preventDefault();
+        page.show('/login/' + encodeURIComponent(
+            window.location.pathname + window.location.hash));
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-change-list-item.html b/polygerrit-ui/app/elements/gr-change-list-item.html
new file mode 100644
index 0000000..ef0f864
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-change-list-item.html
@@ -0,0 +1,207 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../styles/gr-change-list-styles.html">
+<link rel="import" href="gr-date-formatter.html">
+
+<dom-module id="gr-change-list-item">
+  <template>
+    <style>
+      :host {
+        display: flex;
+      }
+      :host([selected]) {
+        background-color: #d8EdF9;
+      }
+      .cell {
+        border-bottom: 1px solid #eee;
+        flex-shrink: 0;
+        padding: .3em .5em;
+      }
+      a {
+        color: var(--default-text-color);
+        text-decoration: none;
+      }
+      a:hover {
+        text-decoration: underline;
+      }
+      .positionIndicator {
+        visibility: hidden;
+      }
+      :host([selected]) .positionIndicator {
+        visibility: visible;
+      }
+      .avatarImage {
+        border-radius: 50%;
+        height: 1.3em;
+        vertical-align: -.3em;
+        width: 1.3em;
+      }
+      .u-monospace {
+        font-family: 'Source Code Pro';
+      }
+      .u-green {
+        color: #388E3C;
+      }
+      .u-red {
+        color: #D32F2F;
+      }
+    </style>
+    <style include="gr-change-list-styles"></style>
+    <span class="cell keyboard">
+      <span class="positionIndicator">&#x25b6;</span>
+    </span>
+    <a class="cell subject" href$="[[changeURL]]">[[change.subject]]</a>
+    <span class="cell status">[[_computeChangeStatusString(change)]]</span>
+    <span class="cell owner">
+      <template is="dom-if" if="[[showAvatar]]">
+        <img class="avatarImage" src$="[[_computeAvatarURL(change.owner)]]">
+      </template>
+      <a href$="[[_computeOwnerLink(change.owner.email)]]"
+         title$="[[_computeOwnerTitle(change.owner)]]">[[change.owner.name]]</a>
+    </span>
+    <a class="cell project" href$="[[_computeProjectURL(change.project)]]">[[change.project]]</a>
+    <a class="cell branch" href$="[[_computeProjectBranchURL(change.project, change.branch)]]">[[change.branch]]</a>
+    <gr-date-formatter class="cell updated" date-str="[[change.updated]]"></gr-date-formatter>
+    <span class="cell size u-monospace">
+      <span class="u-green"><span>+</span>[[change.insertions]]</span>,
+      <span class="u-red"><span>-</span>[[change.deletions]]</span>
+    </span>
+    <span title="Code-Review"
+        class$="[[_computeCodeReviewClass(change.labels.Code_Review)]]">[[_computeCodeReviewLabel(change.labels.Code_Review)]]</span>
+    <span class="cell verified u-green" title="Verified">[[_computeVerifiedLabel(change.labels.Verified)]]</span>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-change-list-item',
+
+      properties: {
+        selected: {
+          type: Boolean,
+          reflectToAttribute: true,
+        },
+        change: Object,
+        changeURL: {
+          type: String,
+          computed: '_computeChangeURL(change._number)',
+        },
+        showAvatar: {
+          type: Boolean,
+          value: false,
+        },
+      },
+
+      ready: function() {
+        app.configReady.then(function(cfg) {
+          this.showAvatar = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+        }.bind(this));
+      },
+
+      _computeChangeURL: function(changeNum) {
+        if (!changeNum) { return ''; }
+        return '/c/' + changeNum + '/';
+      },
+
+      _computeChangeStatusString: function(change) {
+        if (change.mergeable != null && change.mergeable == false) {
+          return 'Merge Conflict';
+        }
+        if (change.status == Changes.Status.MERGED) {
+          return 'Merged';
+        }
+        if (change.status == Changes.Status.DRAFT) {
+          return 'Draft';
+        }
+        if (change.status == Changes.Status.ABANDONED) {
+          return 'Abandoned';
+        }
+        return '';
+      },
+
+      _computeCodeReviewClass: function(codeReview) {
+        // Mimic a Set.
+        var classes = {
+          'cell': true,
+          'codeReview': true,
+        };
+        if (codeReview) {
+          if (codeReview.approved) {
+            classes['u-green'] = true;
+          }
+          if (codeReview.value == 1) {
+            classes['u-monospace'] = true;
+            classes['u-green'] = true;
+          } else if (codeReview.value == -1) {
+            classes['u-monospace'] = true;
+            classes['u-red'] = true;
+          }
+        }
+        return Object.keys(classes).sort().join(' ');
+      },
+
+      _computeCodeReviewLabel: function(codeReview) {
+        if (!codeReview) { return ''; }
+        if (codeReview.approved) {
+          return '✓';
+        }
+        if (codeReview.value == 1) {
+          return '+1';
+        }
+        if (codeReview.value == -1) {
+          return '-1';
+        }
+        return '';
+      },
+
+      _computeVerifiedLabel: function(verified) {
+        if (verified && verified.approved) {
+          return '✓';
+        }
+        return ''
+      },
+
+      _computeAvatarURL: function(owner) {
+        if (!owner) { return ''; }
+        return '/accounts/' + owner.email + '/avatar?s=32'
+      },
+
+      _computeOwnerLink: function(email) {
+        if (!email) { return ''; }
+        return '/q/owner:' + encodeURIComponent(email) + '+status:open';
+      },
+
+      _computeOwnerTitle: function(owner) {
+        if (!owner) { return ''; }
+        // TODO: Is this safe from XSS attacks?
+        return owner.name + ' <' + owner.email + '>';
+      },
+
+      _computeProjectURL: function(project) {
+        return '/projects/' + project + ',dashboards/default';
+      },
+
+      _computeProjectBranchURL: function(project, branch) {
+        return '/q/status:open+project:' + project + '+branch:' + branch;
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-change-list-view.html b/polygerrit-ui/app/elements/gr-change-list-view.html
new file mode 100644
index 0000000..e7cb9ce
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-change-list-view.html
@@ -0,0 +1,128 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="gr-ajax.html">
+<link rel="import" href="gr-change-list.html">
+
+<dom-module id="gr-change-list-view">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+        margin: 0 1.25rem;
+      }
+      gr-change-list {
+        margin-top: 1em;
+        width: 100%;
+      }
+      nav {
+        margin-bottom: 1em;
+        padding: .5em 0;
+        text-align: center;
+      }
+      nav a {
+        display: inline-block;
+      }
+      nav a:first-of-type {
+        margin-right: .5em;
+      }
+      [hidden] {
+        display: none !important;
+      }
+    </style>
+    <gr-ajax
+        auto
+        url="/changes/"
+        params="[[_computeQueryParams(query, offset)]]"
+        last-response="{{_changes}}"></gr-ajax>
+    <gr-change-list changes="{{_changes}}"></gr-change-list>
+    <nav>
+      <a href$="[[_computeNavLink(query, offset, -1)]]"
+         hidden$="[[_hidePrevArrow(offset)]]">&larr; Prev</a>
+      <a href$="[[_computeNavLink(query, offset, 1)]]"
+         hidden$="[[_hideNextArrow(_changes.length)]]">Next &rarr;</a>
+    </nav>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    var DEFAULT_NUM_CHANGES = 25;
+
+    Polymer({
+      is: 'gr-change-list-view',
+
+      properties: {
+        /**
+         * URL params passed from the router.
+         */
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
+
+        /**
+         * Change objects loaded from the server.
+         */
+        _changes: Array,
+      },
+
+      _paramsChanged: function(value) {
+        this.query = value.query;
+        this.offset = value.offset || 0;
+      },
+
+      _computeQueryParams: function(query, offset) {
+        var options = Changes.listChangesOptionsToHex(
+            Changes.ListChangesOption.LABELS,
+            Changes.ListChangesOption.DETAILED_ACCOUNTS
+        );
+        var obj = {
+          n: DEFAULT_NUM_CHANGES,  // Number of results to return.
+          O: options,
+          S: offset || 0,
+        };
+        if (query && query.length > 0) {
+          obj.q = query;
+        }
+        return obj;
+      },
+
+      _computeNavLink: function(query, offset, direction) {
+        // Offset could be a string when passed from the router.
+        offset = +(offset || 0);
+        var newOffset = Math.max(0, offset + (25 * direction));
+        var href = '/q/' + query;
+        if (newOffset > 0) {
+          href += ',' + newOffset;
+        }
+        return href;
+      },
+
+      _hidePrevArrow: function(offset) {
+        return offset == 0;
+      },
+
+      _hideNextArrow: function(changesLen) {
+        return changesLen < DEFAULT_NUM_CHANGES;
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-change-list.html b/polygerrit-ui/app/elements/gr-change-list.html
new file mode 100644
index 0000000..8c0b796
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-change-list.html
@@ -0,0 +1,196 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
+<link rel="import" href="../styles/gr-change-list-styles.html">
+<link rel="import" href="gr-change-list-item.html">
+
+<dom-module id="gr-change-list">
+  <template>
+    <style>
+      :host {
+        display: flex;
+        flex-direction: column;
+      }
+      .headerRow {
+        display: flex;
+      }
+      .topHeader,
+      .groupHeader {
+        border-bottom: 1px solid #eee;
+        font-weight: bold;
+        padding: .3em .5em;
+      }
+      .topHeader {
+        background-color: #ddd;
+        flex-shrink: 0;
+      }
+    </style>
+    <style include="gr-change-list-styles"></style>
+    <div class="headerRow">
+      <span class="topHeader keyboard"></span> <!-- keyboard position indicator -->
+      <span class="topHeader subject">Subject</span>
+      <span class="topHeader status">Status</span>
+      <span class="topHeader owner">Owner</span>
+      <span class="topHeader project">Project</span>
+      <span class="topHeader branch">Branch</span>
+      <span class="topHeader updated">Updated</span>
+      <span class="topHeader size">Size</span>
+      <span class="topHeader codeReview" title="Code-Review">CR</span>
+      <span class="topHeader verified" title="Verified">V</span>
+    </div>
+    <template is="dom-repeat" items="{{groups}}" as="changeGroup" index-as="groupIndex">
+      <template is="dom-if" if="[[_groupTitle(groupIndex)]]">
+        <div class="groupHeader">[[_groupTitle(groupIndex)]]</div>
+      </template>
+      <template is="dom-repeat" items="[[changeGroup]]" as="change">
+        <gr-change-list-item change="[[change]]"
+            selected="[[_isSelected(groupIndex, index)]]"></gr-change-list-item>
+      </template>
+    </template>
+  </template>
+
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-change-list',
+
+      behaviors: [
+        Polymer.IronA11yKeysBehavior
+      ],
+
+      hostAttributes: {
+        tabindex: 0,
+      },
+
+      properties: {
+        /**
+         * An array of ChangeInfo objects to render.
+         * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+         */
+        changes: {
+          type: Array,
+          observer: '_changesChanged',
+        },
+        /**
+         * ChangeInfo objects grouped into arrays. The groups and changes
+         * properties should not be used together.
+         */
+        groups: {
+          type: Array,
+          value: function() { return []; },
+          observer: '_groupsChanged',
+        },
+        keyEventTarget: {
+          type: Object,
+          value: function() {
+            return document.body;
+          }
+        },
+        groupTitles: {
+          type: Array,
+          value: function() { return []; },
+        },
+        selectedIndex: {
+          type: Number,
+          value: 0,
+          observer: '_selectedIndexChanged',
+        },
+      },
+
+      keyBindings: {
+        'j k o enter': '_handleKey',
+      },
+
+      _isSelected: function(groupIndex, index) {
+        return index == this.selectedIndex;
+      },
+
+      _changesChanged: function(changes) {
+        this.groups = [changes];
+      },
+
+      _groupsChanged: function(groups) {
+        for (var i = 0; i < groups.length; i++) {
+          for (var j = 0; j < groups[i].length; j++) {
+            var change = groups[i][j];
+            if (change.labels && change.labels.hasOwnProperty('Code-Review')) {
+              // Transform Code-Review to Code_Review so it is a JS identifier
+              // that can be used in computed properties. This is a hack, but
+              // it'll all have to change to support dynamic label sets anyway.
+              change.labels['Code_Review'] = change.labels['Code-Review'];
+              delete change.labels['Code-Review'];
+            }
+          }
+        }
+      },
+
+      _groupTitle: function(groupIndex) {
+        if (groupIndex > this.groupTitles.length - 1) { return null; }
+        return this.groupTitles[groupIndex];
+      },
+
+      _selectedIndexChanged: function(value) {
+        // Don't re-render the entire list.
+        var changeEls = this._getListItems();
+        for (var i = 0; i < changeEls.length; i++) {
+          changeEls[i].toggleAttribute('selected', i == value);
+        }
+      },
+
+      _handleKey: function(e) {
+        if (util.shouldSupressKeyboardShortcut(e)) { return; }
+
+        if (this.groups == null) { return; }
+        var len = 0;
+        this.groups.forEach(function(group) {
+          len += group.length;
+        });
+        switch(e.detail.combo) {
+          case 'j':
+            if (this.selectedIndex == len - 1) { return; }
+            this.selectedIndex += 1;
+            break;
+          case 'k':
+            if (this.selectedIndex == 0) { return; }
+            this.selectedIndex -= 1;
+            break;
+          case 'o':
+          case 'enter':
+            page.show(this._changeURLForIndex(this.selectedIndex));
+            break;
+        }
+      },
+
+      _changeURLForIndex: function(index) {
+        var changeEls = this._getListItems();
+        if (index < changeEls.length && changeEls[index]) {
+          return changeEls[index].changeURL;
+        }
+        return '';
+      },
+
+      _getListItems: function() {
+        return Polymer.dom(this.root).querySelectorAll('gr-change-list-item');
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-change-view.html b/polygerrit-ui/app/elements/gr-change-view.html
new file mode 100644
index 0000000..8bef101
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-change-view.html
@@ -0,0 +1,322 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
+<link rel="import" href="gr-ajax.html">
+<link rel="import" href="gr-date-formatter.html">
+<link rel="import" href="gr-file-list.html">
+<link rel="import" href="gr-messages-list.html">
+<link rel="import" href="gr-reply-dropdown.html">
+
+<dom-module id="gr-change-view">
+  <template>
+    <style>
+      .container {
+        margin: 1em 0;
+      }
+      .container:not(.loading) {
+        background-color: var(--view-background-color);
+      }
+      .container.loading {
+        color: #666;
+      }
+      .headerContainer {
+        height: 4.1em;
+      }
+      .header {
+        background-color: var(--view-background-color);
+        display: flex;
+        max-width: var(--max-constrained-width);
+        padding: 1em var(--default-horizontal-margin);
+        white-space: nowrap;
+        width: 100%;
+        z-index: 1000;
+      }
+      .header.pinned {
+        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+        position: fixed;
+        top: 0;
+        transition: box-shadow 250ms linear;
+      }
+      .header h2 {
+        flex: 1;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+      section {
+        margin: 10px 0;
+        padding: 10px var(--default-horizontal-margin);
+      }
+      section:first-of-type {
+        margin-top: 0;
+      }
+      table {
+        border-collapse: collapse;
+      }
+      td {
+        padding: 2px 5px;
+        vertical-align: top;
+      }
+      .changeInfo-label {
+        font-weight: bold;
+        text-align: right;
+      }
+      .summary {
+        border-top: 1px solid #ddd;
+        border-bottom: 1px solid #ddd;
+        font-family: 'Source Code Pro', Menlo, 'Lucida Console', Monaco, monospace;
+        overflow-x: auto;
+        white-space: pre-wrap;
+      }
+      gr-file-list {
+        padding: 0 var(--default-horizontal-margin) 10px;
+      }
+    </style>
+    <gr-ajax id="detailXHR"
+        url="[[_computeDetailPath(changeNum)]]"
+        params="[[_computeDetailQueryParams()]]"
+        last-response="{{change}}"
+        loading="{{_loading}}"></gr-ajax>
+    <gr-ajax id="commentsXHR"
+        url="[[_computeCommentsPath(changeNum)]]"
+        last-response="{{comments}}"></gr-ajax>
+    <div class="container loading" hidden$="{{!_loading}}">Loading...</div>
+    <div class="container" hidden$="{{_loading}}">
+      <div class="headerContainer">
+        <div class="header">
+          <h2>
+            <a href$="[[_computeChangePath(change._number)]]">[[change._number]]</a><span>:</span>
+            <span>[[change.subject]]</span>
+          </h2>
+          <gr-reply-dropdown id="replyDropdown"
+              change-num="[[changeNum]]"
+              patch-num="[[_computePatchNum(change.current_revision)]]"
+              labels="[[change.labels]]"
+              permitted-labels="[[change.permitted_labels]]"
+              on-send="_handleReplySent"
+              hidden$="[[!_loggedIn]]">Reply</gr-reply-dropdown>
+        </div>
+      </div>
+      <section class="changeInfo">
+        <table>
+          <tr>
+            <td class="changeInfo-label">Owner</td>
+            <td>[[change.owner.name]]</td>
+          </tr>
+          <tr>
+            <td class="changeInfo-label">Reviewers</td>
+            <td>
+              <template is="dom-repeat"
+                        items="[[_computeReviewers(change.labels, change.owner)]]"
+                        as="reviewer">
+                <div>[[reviewer.name]]</div>
+              </template>
+            </td>
+          </tr>
+          <tr>
+            <td class="changeInfo-label">Project</td>
+            <td>[[change.project]]</td>
+          </tr>
+          <tr>
+            <td class="changeInfo-label">Branch</td>
+            <td>[[change.branch]]</td>
+          </tr>
+          <tr>
+            <td class="changeInfo-label">Topic</td>
+            <td>[[change.topic]]</td>
+          </tr>
+          <tr>
+            <td class="changeInfo-label">Strategy</td>
+            <td></td>
+          </tr>
+          <tr>
+            <td class="changeInfo-label">Updated</td>
+            <td>
+              <gr-date-formatter
+                  date-str="[[change.updated]]"></gr-date-formatter>
+            </td>
+          </tr>
+        </table>
+      </section>
+      <section class="summary">[[_computeCurrentRevisionMessage(change)]]</section>
+      <gr-file-list id="fileList"
+          change-num="[[changeNum]]"
+          patch-num="[[_computePatchNum(change.current_revision)]]"
+          revision="[[change.current_revision]]"
+          comments="[[comments]]"></gr-file-list>
+      <gr-messages-list
+          change-num="[[changeNum]]"
+          messages="[[change.messages]]"
+          comments="[[comments]]"></gr-messages-list>
+    </div>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-change-view',
+
+      behaviors: [
+        Polymer.IronA11yKeysBehavior
+      ],
+
+      properties: {
+        keyEventTarget: {
+          type: Object,
+          value: function() {
+            return document.body;
+          },
+        },
+        /**
+         * URL params passed from the router.
+         */
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
+        changeNum: Number,
+        comments: Object,
+
+        _loggedIn: {
+          type: Boolean,
+          value: false,
+        },
+        _loading: Boolean,
+        _headerContainerEl: Object,
+        _headerEl: Object,
+        _scrollHandler: Function,
+      },
+
+      keyBindings: {
+        'a u': '_handleKey',
+      },
+
+      ready: function() {
+        app.accountReady.then(function() {
+          this._loggedIn = app.loggedIn;
+        }.bind(this));
+        this._scrollHandler = this._handleBodyScroll.bind(this);
+      },
+
+      attached: function() {
+        window.addEventListener('scroll', this._scrollHandler);
+      },
+
+      detached: function() {
+        window.removeEventListener('scroll', this._scrollHandler);
+      },
+
+      _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; }
+
+        var el = this._headerEl || this.$$('.header');
+        this._headerEl = el;
+        el.classList.toggle('pinned', window.scrollY >= top);
+      },
+
+      _handleReplySent: function(e) {
+        this._reload();
+      },
+
+      _paramsChanged: function(value) {
+        this.changeNum = value.changeNum;
+        if (!this.changeNum) {
+          this.change = null;
+          this.comments = null;
+          return;
+        }
+        this._reload();
+      },
+
+      _computeChangePath: function(changeNum) {
+        return '/c/' + changeNum;
+      },
+
+      _computeDetailPath: function(changeNum) {
+        return '/changes/' + changeNum + '/detail';
+      },
+
+      _computeCommitInfoPath: function(changeNum, commitHash) {
+        return '/changes/' + changeNum + '/revisions/' + commitHash + '/commit';
+      },
+
+      _computeCommentsPath: function(changeNum) {
+        return '/changes/' + changeNum + '/comments';
+      },
+
+      _computePatchNum: function(revision) {
+        return this.change && this.change.revisions[revision]._number;
+      },
+
+      _computeDetailQueryParams: function() {
+        var options = Changes.listChangesOptionsToHex(
+            Changes.ListChangesOption.CURRENT_REVISION,
+            Changes.ListChangesOption.CURRENT_COMMIT,
+            Changes.ListChangesOption.CHANGE_ACTIONS
+        );
+        return { O: options };
+      },
+
+      _computeCurrentRevisionMessage: function(change) {
+        return change &&
+            change.revisions[change.current_revision].commit.message;
+      },
+
+      _computeReviewers: function(labels, owner) {
+        var reviewers =
+            (labels['Code-Review'] && labels['Code-Review'].all) || [];
+        if (reviewers.length == 1) { return reviewers; }
+        return reviewers.filter(function(reviewer) {
+          return reviewer._account_id != owner._account_id;
+        });
+      },
+
+      _handleKey: function(e) {
+        if (util.shouldSupressKeyboardShortcut(e)) { return; }
+        e.preventDefault();
+        switch(e.detail.combo) {
+          case 'a':
+            this.$.replyDropdown.open();
+            break;
+          case 'u':
+            page.show('/');
+            break;
+        }
+      },
+
+      _reload: function() {
+        this.$.detailXHR.generateRequest();
+        this.$.commentsXHR.generateRequest();
+        this.$.fileList.reload();
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-comment-list.html b/polygerrit-ui/app/elements/gr-comment-list.html
new file mode 100644
index 0000000..3c172e5
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-comment-list.html
@@ -0,0 +1,117 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-comment-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      .file {
+        border-top: 1px solid #ddd;
+        font-weight: bold;
+        margin: 10px 0 3px;
+        padding: 10px 0 5px;
+      }
+      .container {
+        display: flex;
+        margin: 5px 0;
+      }
+      .lineNum {
+        margin-right: .35em;
+        min-width: 7em;
+      }
+      .message {
+        flex: 1;
+        white-space: pre-wrap;
+      }
+    </style>
+    <template is="dom-repeat" items="{{_files}}" as="file">
+      <div class="file">
+        <a href$="[[_computeFileDiffURL(file, changeNum, patchNum)]]">[[file]]</a>:
+      </div>
+      <template is="dom-repeat"
+                items="[[_computeCommentsForFile(file)]]" as="comment">
+        <div class="container">
+          <a class="lineNum"
+             href$="[[_computeDiffLineURL(file, changeNum, comment.patch_set, comment)]]">
+             <span hidden$="[[!comment.line]]">
+               <span>[[_computePatchDisplayName(comment)]]</span>
+               Line <span>[[comment.line]]</span>:
+             </span>
+             <span hidden$="[[comment.line]]">
+               File comment:
+             </span>
+          </a>
+          <div class="message">[[comment.message]]</div>
+        </div>
+      </template>
+    </template>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-comment-list',
+
+      properties: {
+        changeNum: Number,
+        comments: {
+          type: Object,
+          observer: '_commentsChanged',
+        },
+        patchNum: Number,
+
+        _files: Array,
+      },
+
+      _commentsChanged: function(value) {
+        this._files = Object.keys(value || {}).sort();
+      },
+
+      _computeFileDiffURL: function(file, changeNum, patchNum) {
+        return '/c/' + changeNum + '/' + patchNum + '/' + file;
+      },
+
+      _computeDiffLineURL: function(file, changeNum, patchNum, comment) {
+        var diffURL = this._computeFileDiffURL(file, changeNum, patchNum);
+        if (comment.line) {
+          // TODO(andybons): This is not correct if the comment is on the base.
+          diffURL += '#' + comment.line;
+        }
+        return diffURL;
+      },
+
+      _computeCommentsForFile: function(file) {
+        return this.comments[file];
+      },
+
+      _computePatchDisplayName: function(comment) {
+        if (comment.side == 'PARENT') {
+          return 'Base, ';
+        }
+        if (comment.patch_set != this.patchNum) {
+          return 'PS' + comment.patch_set + ', ';
+        }
+        return '';
+      }
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-dashboard-view.html b/polygerrit-ui/app/elements/gr-dashboard-view.html
new file mode 100644
index 0000000..88997f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-dashboard-view.html
@@ -0,0 +1,78 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-dashboard-view">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+        margin: 0 1.25rem;
+      }
+      gr-change-list {
+        margin-top: 1em;
+        width: 100%;
+      }
+    </style>
+    <gr-ajax
+        auto
+        url="/changes/"
+        params="[[_computeQueryParams()]]"
+        last-response="{{_results}}"></gr-ajax>
+    <gr-change-list groups="{{_results}}"
+        group-titles="[[_groupTitles]]"></gr-change-list>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-dashboard-view',
+
+      properties: {
+        _results: Array,
+        _groupTitles: {
+          type: Array,
+          value: [
+            'Outgoing reviews',
+            'Incoming reviews',
+            'Recently closed',
+          ],
+        },
+      },
+
+      _computeQueryParams: function() {
+        var options = Changes.listChangesOptionsToHex(
+            Changes.ListChangesOption.LABELS,
+            Changes.ListChangesOption.DETAILED_ACCOUNTS,
+            Changes.ListChangesOption.REVIEWED
+        );
+        return {
+          O: options,
+          q: [
+            'is:open owner:self',
+            'is:open reviewer:self -owner:self',
+            'is:closed (owner:self OR reviewer:self) -age:4w limit:10',
+          ],
+        };
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-date-formatter.html b/polygerrit-ui/app/elements/gr-date-formatter.html
new file mode 100644
index 0000000..55782f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-date-formatter.html
@@ -0,0 +1,90 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-date-formatter">
+  <template>
+    <style>
+      :host {
+        display: inline;
+      }
+    </style>
+    <span>[[_computeDateStr(dateStr)]]</span>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    var Duration = {
+      HOUR: 1000 * 60 * 60,
+      DAY: 1000 * 60 * 60 * 24,
+    };
+
+    var ShortMonthNames = [
+      'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct',
+      'Nov', 'Dec'
+    ];
+
+    Polymer({
+      is: 'gr-date-formatter',
+
+      properties: {
+        dateStr: {
+          type: String,
+          value: null,
+          notify: true
+        }
+      },
+
+      _computeDateStr: function(dateStr) {
+        return this._dateStr(this._parseDateStr(dateStr), new Date());
+      },
+
+      _parseDateStr: function(dateStr) {
+        if (!dateStr) { return null; }
+        return util.parseDate(dateStr);
+      },
+
+      _dateStr: function(t, now) {
+        if (!t) { return ''; }
+        var diff = now.getTime() - t.getTime();
+        if (diff < Duration.DAY && t.getDay() == now.getDay()) {
+          // Within 24 hours and on the same day:
+          // '2:14 AM'
+          var pm = t.getHours() >= 12;
+          var hours = t.getHours() === 0 ? 12 :
+              pm ? t.getHours() - 12 : t.getHours();
+          var minutes = t.getMinutes() < 10 ? '0' + t.getMinutes() :
+              t.getMinutes();
+          return hours + ':' + minutes + (pm ? ' PM' : ' AM');
+        } else if ((t.getDay() != now.getDay() || diff >= Duration.DAY) &&
+                   diff < 180 * Duration.DAY) {
+          // From one to six months:
+          // 'Aug 29'
+          return ShortMonthNames[t.getMonth()] + ' ' + t.getDate();
+        } else if (diff >= 180 * Duration.DAY) {
+          // More than six months:
+          // 'Aug 29, 1997'
+          return ShortMonthNames[t.getMonth()] + ' ' + t.getDate() + ', ' +
+              t.getFullYear();
+        }
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/gr-diff-comment-thread.html
new file mode 100644
index 0000000..5454914
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-diff-comment-thread.html
@@ -0,0 +1,160 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="gr-diff-comment.html">
+
+<dom-module id="gr-diff-comment-thread">
+  <template>
+    <style>
+      :host {
+        display: block;
+        max-width: 50em;
+        white-space: normal;
+      }
+    </style>
+    <template id="commentList" is="dom-repeat" items="{{_orderedComments}}" as="comment">
+      <gr-diff-comment
+          comment="{{comment}}"
+          change-num="[[changeNum]]"
+          patch-num="[[patchNum]]"
+          draft="[[comment.__draft]]"
+          editing="[[!comment.message]]"></gr-diff-comment>
+    </template>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+
+    Polymer({
+      is: 'gr-diff-comment-thread',
+
+      /**
+       * Fired when the height of the thread changes.
+       *
+       * @event gr-diff-comment-thread-height-changed
+       */
+
+      /**
+       * Fired when the thread should be discarded.
+       *
+       * @event gr-diff-comment-thread-discard
+       */
+
+      properties: {
+        changeNum: String,
+        comments: {
+          type: Array,
+          value: function() { return []; },
+          observer: '_commentsChanged',
+        },
+        patchNum: String,
+
+        _orderedComments: Array,
+      },
+
+      ready: function() {
+        this.addEventListener('gr-diff-comment-height-changed',
+            this._handleCommentHeightChange.bind(this));
+        this.addEventListener('gr-diff-comment-reply',
+            this._handleCommentReply.bind(this));
+        this.addEventListener('gr-diff-comment-discard',
+            this._handleCommentDiscard.bind(this));
+      },
+
+      _commentsChanged: function(comments) {
+        this._orderedComments = this._sortedComments(comments);
+      },
+
+      _sortedComments: function(comments) {
+        comments.sort(function(c1, c2) {
+          return util.parseDate(c1.updated) - util.parseDate(c2.updated);
+        });
+
+        var commentIDToReplies = {};
+        var topLevelComments = [];
+        for (var i = 0; i < comments.length; i++) {
+          var c = comments[i];
+          if (c.in_reply_to) {
+            if (commentIDToReplies[c.in_reply_to] == null) {
+              commentIDToReplies[c.in_reply_to] = [];
+            }
+            commentIDToReplies[c.in_reply_to].push(c);
+          } else {
+            topLevelComments.push(c);
+          }
+        }
+        var results = [];
+        for (var i = 0; i < topLevelComments.length; i++) {
+          this._visitComment(topLevelComments[i], commentIDToReplies, results);
+        }
+        return results;
+      },
+
+      _visitComment: function(parent, commentIDToReplies, results) {
+        results.push(parent);
+
+        var replies = commentIDToReplies[parent.id];
+        if (!replies) { return; }
+        for (var i = 0; i < replies.length; i++) {
+          this._visitComment(replies[i], commentIDToReplies, results);
+        }
+      },
+
+      _handleCommentHeightChange: function(e) {
+        // TODO: This fires for each comment on initialization. Optimize to only
+        // fire the top level "thread height has changed" event once during
+        // initial DOM stamp.
+        this.fire('gr-diff-comment-thread-height-changed',
+            {height: this.offsetHeight});
+      },
+
+      _handleCommentReply: function(e) {
+        console.log('should add reply...')
+      },
+
+      _handleCommentDiscard: function(e) {
+        var diffCommentEl = e.target;
+        var idx = this._indexOf(diffCommentEl.comment, this.comments);
+        if (idx == -1) {
+          throw Error('Cannot find comment ' +
+              JSON.stringify(diffCommentEl.comment));
+        }
+        this.comments.splice(idx, 1);
+        this._commentsChanged(this.comments);
+        if (this.comments.length == 0 && this.parentNode) {
+          this.parentNode.removeChild(this);
+        }
+        this.fire('gr-diff-comment-thread-height-changed',
+            {height: this.offsetHeight});
+      },
+
+      _indexOf: function(comment, arr) {
+        for (var i = 0; i < arr.length; i++) {
+          var c = arr[i];
+          if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
+              (c.id != null && c.id == comment.id)) {
+            return i;
+          }
+        }
+        return -1;
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-diff-comment.html b/polygerrit-ui/app/elements/gr-diff-comment.html
new file mode 100644
index 0000000..d8c2537
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-diff-comment.html
@@ -0,0 +1,344 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="gr-date-formatter.html">
+<link rel="import" href="gr-request.html">
+
+<dom-module id="gr-diff-comment">
+  <template>
+    <style>
+      :host {
+        border: 1px solid #ddd;
+        display: block;
+      }
+      :host([disabled]) {
+        pointer-events: none;
+      }
+      :host([disabled]) .container {
+        opacity: .5;
+      }
+      .header,
+      .message,
+      .actions {
+        padding: .5em .7em;
+      }
+      .header {
+        background-color: #eee;
+        display: flex;
+        font-family: 'Open Sans', sans-serif;
+      }
+      .headerLeft {
+        flex: 1;
+      }
+      .authorName,
+      .draftLabel {
+        font-weight: bold;
+      }
+      .draftLabel {
+        color: #999;
+        display: none;
+      }
+      .date {
+        justify-content: flex-end;
+        margin-left: 5px;
+      }
+      a.date:link,
+      a.date:visited {
+        color: #666;
+        text-decoration: none;
+      }
+      a.date:hover {
+        text-decoration: underline;
+      }
+      .message {
+        white-space: pre-wrap;
+      }
+      .actions {
+        display: flex;
+        padding-top: 0;
+      }
+      .action {
+        margin-right: 1em;
+      }
+      .action[disabled] {
+        opacity: .5;
+        pointer-events: none;
+      }
+      .danger {
+        display: flex;
+        flex: 1;
+        justify-content: flex-end;
+      }
+      .editMessage {
+        display: none;
+        margin: .5em .7em;
+        width: calc(100% - 1.4em - 2px);
+      }
+      .danger .action {
+        margin-right: 0;
+      }
+      .container:not(.draft) .actions :not(.reply):not(.done) {
+        display: none;
+      }
+      .draft .reply,
+      .draft .done {
+        display: none;
+      }
+      .draft .draftLabel {
+        display: inline;
+      }
+      .draft:not(.editing) .save,
+      .draft:not(.editing) .cancel {
+        display: none;
+      }
+      .editing .message,
+      .editing .reply,
+      .editing .done,
+      .editing .edit {
+        display: none;
+      }
+      .editing .editMessage {
+        display: block;
+      }
+    </style>
+    <div class="container" id="container">
+      <div class="header" id="header">
+        <div class="headerLeft">
+          <span class="authorName">[[comment.author.name]]</span>
+          <span class="draftLabel">DRAFT</span>
+        </div>
+        <a class="date" href$="[[_computeLinkToComment(comment)]]" on-tap="_handleLinkTap">
+          <gr-date-formatter date-str="[[comment.updated]]"></gr-date-formatter>
+        </a>
+      </div>
+      <iron-autogrow-textarea
+          id="editTextarea"
+          class="editMessage"
+          disabled="{{disabled}}"
+          rows="4"
+          max-rows="4"
+          bind-value="{{_editDraft}}"></iron-autogrow-textarea>
+      <div class="message">[[comment.message]]</div>
+      <div class="actions">
+        <a class="action reply" href="#" on-tap="_handleReply">Reply</a>
+        <a class="action done" href="#" on-tap="_handleDone">Done</a>
+        <a class="action edit" href="#" on-tap="_handleEdit">Edit</a>
+        <a class="action save" href="#"
+            disabled$="[[_computeSaveDisabled(_editDraft)]]"
+            on-tap="_handleSave">Save</a>
+        <a class="action cancel" href="#" on-tap="_handleCancel">Cancel</a>
+        <div class="danger">
+          <a class="action discard" href="#" on-tap="_handleDiscard">Discard</a>
+        </div>
+      </div>
+    </div>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-diff-comment',
+
+      /**
+       * Fired when the height of the comment changes.
+       *
+       * @event gr-diff-comment-height-changed
+       */
+
+      /**
+       * Fired when the Reply action is triggered.
+       *
+       * @event gr-diff-comment-reply
+       */
+
+      /**
+       * Fired when the Done action is triggered.
+       *
+       * @event gr-diff-comment-done
+       */
+
+      /**
+       * Fired when this comment is discarded.
+       *
+       * @event gr-diff-comment-discard
+       */
+
+      properties: {
+        changeNum: String,
+        comment: {
+          type: Object,
+          notify: true,
+        },
+        disabled: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        draft: {
+          type: Boolean,
+          value: false,
+          observer: '_draftChanged',
+        },
+        editing: {
+          type: Boolean,
+          value: false,
+          observer: '_editingChanged',
+        },
+        patchNum: String,
+
+        _xhrPromise: Object,  // Used for testing.
+        _editDraft: String,
+      },
+
+      attached: function() {
+        this._heightChanged();
+      },
+
+      _heightChanged: function() {
+        this.async(function() {
+          this.fire('gr-diff-comment-height-changed',
+            {height: this.offsetHeight});
+        }.bind(this));
+      },
+
+      _draftChanged: function(draft) {
+        this.$.container.classList.toggle('draft', draft);
+      },
+
+      _editingChanged: function(editing) {
+        this.$.container.classList.toggle('editing', editing);
+        if (editing) {
+          this.async(function() {
+            this.$.editTextarea.textarea.focus();
+          }.bind(this));
+        }
+        this._heightChanged();
+      },
+
+      _computeLinkToComment: function(comment) {
+        return '#' + comment.line;
+      },
+
+      _computeSaveDisabled: function(draft) {
+        return draft == null || draft.trim() == '';
+      },
+
+      _handleLinkTap: function(e) {
+        e.preventDefault();
+        var hash = this._computeLinkToComment(this.comment);
+        // Don't add the hash to the window history if it's already there.
+        // Otherwise you mess up expected back button behavior.
+        if (window.location.hash == hash) { return; }
+        // Change the URL but don’t trigger a nav event. Otherwise it will
+        // reload the page.
+        page.show(window.location.pathname + hash, null, false);
+      },
+
+      _handleReply: function(e) {
+        e.preventDefault();
+        this.fire('gr-diff-comment-reply');
+      },
+
+      _handleDone: function(e) {
+        e.preventDefault();
+        this.fire('gr-diff-comment-done');
+      },
+
+      _handleEdit: function(e) {
+        e.preventDefault();
+        this._editDraft = this.comment.message;
+        this.editing = true;
+      },
+
+      _handleSave: function(e) {
+        e.preventDefault();
+        this.comment.message = this._editDraft;
+        this.disabled = true;
+        var endpoint = this._restEndpoint(this.comment.id);
+        this._send('PUT', endpoint).then(function(req) {
+          this.disabled = false;
+          var comment = req.response;
+          comment.__draft = true;
+          // Maintain the ephemeral draft ID for identification by other
+          // elements.
+          if (this.comment.__draftID) {
+            comment.__draftID = this.comment.__draftID;
+          }
+          this.comment = comment;
+          this.editing = false;
+        }.bind(this)).catch(function(err) {
+          alert('Your draft couldn’t be saved. Check the console and contact ' +
+              'the PolyGerrit team for assistance.');
+          this.disabled = false;
+        }.bind(this));
+      },
+
+      _handleCancel: function(e) {
+        e.preventDefault();
+        if (this.comment.message == null || this.comment.message.length == 0) {
+          this.fire('gr-diff-comment-discard');
+          return;
+        }
+        this._editDraft = this.comment.message;
+        this.editing = false;
+      },
+
+      _handleDiscard: function(e) {
+        e.preventDefault();
+        if (!this.comment.__draft) {
+          throw Error('Cannot discard a non-draft comment.');
+        }
+        this.disabled = true;
+        var commentID = this.comment.id;
+        if (!commentID) {
+          this.fire('gr-diff-comment-discard');
+          return;
+        }
+        this._send('DELETE', this._restEndpoint(commentID)).then(function(req) {
+          this.fire('gr-diff-comment-discard', this.comment);
+        }.bind(this)).catch(function(err) {
+          alert('Your draft couldn’t be deleted. Check the console and ' +
+              'contact the PolyGerrit team for assistance.');
+          this.disabled = false;
+        }.bind(this));
+      },
+
+      _send: function(method, url) {
+        var xhr = document.createElement('gr-request');
+        this._xhrPromise = xhr.send({
+          method: method,
+          url: url,
+          body: this.comment,
+        });
+        return this._xhrPromise;
+      },
+
+      _restEndpoint: function(id) {
+        var path = '/changes/' + this.changeNum + '/revisions/' +
+            this.patchNum + '/drafts';
+        if (id) {
+          path += '/' + id;
+        }
+        return path;
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-diff-side.html b/polygerrit-ui/app/elements/gr-diff-side.html
new file mode 100644
index 0000000..6e150d6
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-diff-side.html
@@ -0,0 +1,317 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-diff-side">
+  <template>
+    <style>
+      :host,
+      .container {
+        display: flex;
+      }
+      .content {
+        width: 80ch;
+      }
+      .lineNum:before,
+      .code:before {
+        /* To ensure the height is non-zero in these elements, a
+           zero-width space is set as its content. The character
+           itself doesn't matter. Just that there is something
+           there. */
+        content: '\200B';
+      }
+      .lineNum {
+        background-color: #eee;
+        color: #666;
+        padding: 0 .75em;
+        text-align: right;
+      }
+      .canComment .lineNum {
+        cursor: pointer;
+      }
+      .canComment .lineNum:hover {
+        background-color: #ccc;
+      }
+      .code {
+        white-space: pre;
+      }
+      .lightHighlight {
+        background-color: var(--light-highlight-color);
+      }
+      hl,
+      .darkHighlight {
+        background-color: var(--dark-highlight-color);
+      }
+      .br:after {
+        /* Line feed */
+        content: '\A';
+      }
+      .filler {
+        background: #eee;
+      }
+    </style>
+    <div class$="[[_computeContainerClass(canComment)]]">
+      <div class="numbers" id="numbers"></div>
+      <div class="content" id="content"></div>
+    </div>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    var CharCode = {
+      LESS_THAN: '<'.charCodeAt(0),
+      GREATER_THAN: '>'.charCodeAt(0),
+      AMPERSAND: '&'.charCodeAt(0),
+      SEMICOLON: ';'.charCodeAt(0),
+    };
+
+    Polymer({
+      is: 'gr-diff-side',
+
+      properties: {
+        canComment: {
+          type: Boolean,
+          value: false,
+        },
+        content: {
+          type: Array,
+          notify: true,
+          observer: '_render',
+        },
+        width: {
+          type: Number,
+          observer: '_widthChanged',
+        },
+
+        _lineFeedHTML: {
+          type: String,
+          value: '<span class="style-scope gr-diff-side br"></span>',
+          readOnly: true,
+        },
+        _highlightStartTag: {
+          type: String,
+          value: '<hl class="style-scope gr-diff-side">',
+          readOnly: true,
+        },
+        _highlightEndTag: {
+          type: String,
+          value: '</hl>',
+          readOnly: true,
+        },
+      },
+
+      scrollToLine: function(lineNum) {
+        if (isNaN(lineNum) || lineNum < 1) { return; }
+
+        var el = this.$$('.numbers .lineNum[data-line-num="' + lineNum + '"]');
+        if (!el) { return; }
+
+        // Calculate where the line is relative to the window.
+        var top = el.offsetTop;
+        for (var offsetParent = el.offsetParent;
+             offsetParent;
+             offsetParent = offsetParent.offsetParent) {
+          top += offsetParent.offsetTop;
+        }
+
+        // Scroll the element to the middle of the window. Dividing by a third
+        // instead of half the inner height feels a bit better otherwise the
+        // element appears to be below the center of the window even when it
+        // isn't.
+        window.scrollTo(0, top - (window.innerHeight / 3) - el.offsetHeight);
+      },
+
+      _widthChanged: function(width) {
+        this.$.content.style.width = width + 'ch';
+      },
+
+      _computeContainerClass: function(canComment) {
+        return 'container' + (canComment ? ' canComment' : '');
+      },
+
+      _clearChildren: function(el) {
+        while (el.firstChild) {
+          el.removeChild(el.firstChild);
+        }
+      },
+
+      _render: function(diff) {
+        this._clearChildren(this.$.numbers);
+        this._clearChildren(this.$.content);
+        for (var i = 0; i < diff.length; i++) {
+          switch (diff[i].type) {
+            case 'CODE':
+              this._renderCode(diff[i]);
+              break;
+            case 'FILLER':
+              this._renderFiller(diff[i]);
+              break;
+          }
+        }
+      },
+
+      _renderFiller: function(filler) {
+        var lineFillerEl = this._createElement('div', 'filler');
+        var fillerEl = this._createElement('div', 'filler');
+        var numLines = filler.numLines || 1;
+
+        lineFillerEl.textContent = '\n'.repeat(numLines);
+        for (var i = 0; i < numLines; i++) {
+          var newlineEl = this._createElement('span', 'br');
+          fillerEl.appendChild(newlineEl);
+        }
+        this.$.numbers.appendChild(lineFillerEl);
+        this.$.content.appendChild(fillerEl);
+      },
+
+      _renderCode: function(code) {
+        var lineNumEl = this._createElement('div', 'lineNum');
+        lineNumEl.setAttribute('data-line-num', code.lineNum);
+        var numLines = code.numLines || 1;
+        lineNumEl.textContent = code.lineNum + '\n'.repeat(numLines);
+
+        var contentEl = this._createElement('div', 'code');
+        contentEl.setAttribute('data-line-num', code.lineNum);
+
+        if (code.highlight) {
+          contentEl.classList.add(code.intraline.length > 0 ?
+              'lightHighlight' : 'darkHighlight');
+        }
+
+        var html = util.escapeHTML(code.content);
+        if (code.highlight && code.intraline.length > 0) {
+          html = this._addIntralineHighlights(code.content, html,
+              code.intraline);
+        }
+        if (numLines > 1) {
+          html = this._addNewLines(code.content, html, numLines);
+        }
+
+        // If the html is equivalent to the text then it didn't get highlighted
+        // or escaped. Use textContent which is faster than innerHTML.
+        if (code.content == html) {
+          contentEl.textContent = code.content;
+        } else {
+          contentEl.innerHTML = html;
+        }
+
+        this.$.numbers.appendChild(lineNumEl);
+        this.$.content.appendChild(contentEl);
+      },
+
+      // Advance `index` by the appropriate number of characters that would
+      // represent one source code character and return that index. For
+      // example, for source code '<span>' the escaped html string is
+      // '&lt;span&gt;'. Advancing from index 0 on the prior html string would
+      // return 4, since &lt; maps to one source code character ('<').
+      _advanceChar: function(html, index) {
+        // Any tags don't count as characters
+        while (index < html.length &&
+               html.charCodeAt(index) == CharCode.LESS_THAN) {
+          while (index < html.length &&
+                 html.charCodeAt(index) != CharCode.GREATER_THAN) {
+            index++;
+          }
+          index++;  // skip the ">" itself
+        }
+        // An HTML entity (e.g., &lt;) counts as one character.
+        if (index < html.length &&
+            html.charCodeAt(index) == CharCode.AMPERSAND) {
+          while (index < html.length &&
+                 html.charCodeAt(index) != CharCode.SEMICOLON) {
+            index++;
+          }
+        }
+        return index + 1;
+      },
+
+      _addIntralineHighlights: function(content, html, highlights) {
+        var startTag = this._highlightStartTag;
+        var endTag = this._highlightEndTag;
+
+        for (var i = 0; i < highlights.length; i++) {
+          var hl = highlights[i];
+
+          var htmlStartIndex = 0;
+          for (var j = 0; j < hl.startIndex; j++) {
+            htmlStartIndex = this._advanceChar(html, htmlStartIndex);
+          }
+
+          var htmlEndIndex = 0;
+          if (hl.endIndex != null) {
+            for (var j = 0; j < hl.endIndex; j++) {
+              htmlEndIndex = this._advanceChar(html, htmlEndIndex);
+            }
+          } else {
+            // If endIndex isn't present, continue to the end of the line.
+            htmlEndIndex = html.length;
+          }
+          // The start and end indices could be the same if a highlight is meant
+          // to start at the end of a line and continue onto the next one.
+          // Ignore it.
+          if (htmlStartIndex != htmlEndIndex) {
+            html = html.slice(0, htmlStartIndex) + startTag +
+                  html.slice(htmlStartIndex, htmlEndIndex) + endTag +
+                  html.slice(htmlEndIndex);
+          }
+        }
+        return html;
+      },
+
+      _addNewLines: function(content, html, numLines) {
+        var htmlIndex = 0;
+        var indices = [];
+        for (var i = 0; i < content.length; i++) {
+          if (i > 0 && i % this.width == 0) {
+            indices.push(htmlIndex);
+          }
+          htmlIndex = this._advanceChar(html, htmlIndex)
+        }
+        var result = html;
+        var linesLeft = numLines;
+        // Since the result string is being altered in place, start from the end
+        // of the string so that the insertion indices are not affected as the
+        // result string changes.
+        for (var i = indices.length - 1; i >= 0; i--) {
+          result = result.slice(0, indices[i]) + this._lineFeedHTML +
+              result.slice(indices[i]);
+          linesLeft--;
+        }
+        // numLines is the total number of lines this code block should take up.
+        // Fill in the remaining ones.
+        for (var i = 0; i < linesLeft; i++) {
+          result += this._lineFeedHTML;
+        }
+        return result;
+      },
+
+      _createElement: function(tagName, className) {
+        var el = document.createElement(tagName);
+        // When Shady DOM is being used, these classes are added to account for
+        // Polymer's polyfill behavior. In order to guarantee sufficient
+        // specificity within the CSS rules, these are added to every element.
+        // Since the Polymer DOM utility functions (which would do this
+        // automatically) are not being used for performance reasons, this is
+        // done manually.
+        el.classList.add('style-scope', 'gr-diff-side', className);
+        return el;
+      },
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-diff-view.html b/polygerrit-ui/app/elements/gr-diff-view.html
new file mode 100644
index 0000000..4945a67
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-diff-view.html
@@ -0,0 +1,183 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
+<link rel="import" href="gr-ajax.html">
+<link rel="import" href="gr-diff.html">
+
+<dom-module id="gr-diff-view">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+      }
+      h3 {
+        margin-top: 1em;
+        padding: .75em var(--default-horizontal-margin);
+      }
+      .mainContainer {
+        border-bottom: 1px solid #eee;
+        border-collapse: collapse;
+        border-top: 1px solid #eee;
+        width: 100%;
+      }
+    </style>
+    <gr-ajax id="changeDetailXHR"
+        auto
+        url="[[_computeChangeDetailPath(_changeNum)]]"
+        params="[[_computeChangeDetailQueryParams()]]"
+        last-response="{{_change}}"></gr-ajax>
+    <gr-ajax id="filesXHR"
+        auto
+        url="[[_computeFilesPath(_changeNum, _patchRange.patchNum)]]"
+        on-response="_handleFilesResponse"></gr-ajax>
+    <h3>
+      <a href$="[[_computeChangePath(_changeNum)]]">[[_changeNum]]</a><span>:</span>
+      <span>[[_change.subject]]</span> — <span>[[params.path]]</span>
+    </h3>
+    <gr-diff id="diff"
+        auto
+        change-num="[[_changeNum]]"
+        patch-range="[[_patchRange]]"
+        path="[[_path]]"
+        on-render="_handleDiffRender">
+    </gr-diff>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-diff-view',
+
+      behaviors: [
+        Polymer.IronA11yKeysBehavior
+      ],
+
+      properties: {
+        keyEventTarget: {
+          type: Object,
+          value: function() {
+            return document.body;
+          },
+        },
+        /**
+         * URL params passed from the router.
+         */
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
+        _patchRange: Object,
+        _change: Object,
+        _changeNum: String,
+        _diff: Object,
+        _fileList: {
+          type: Array,
+          value: function() { return []; },
+        },
+        _path: String,
+      },
+
+      keyBindings: {
+        '[ ] u': '_handleKey',
+      },
+
+      _handleKey: function(e) {
+        if (util.shouldSupressKeyboardShortcut(e)) { return; }
+
+        switch(e.detail.combo) {
+          case '[':
+            this._navToFile(this._fileList, -1);
+            break;
+          case ']':
+            this._navToFile(this._fileList, 1);
+            break;
+          case 'u':
+            if (this._changeNum) {
+              page.show(this._computeChangePath(this._changeNum));
+            }
+            break;
+        }
+      },
+
+      _handleDiffRender: function() {
+        if (window.location.hash.length > 0) {
+          this.$.diff.scrollToLine(
+              parseInt(window.location.hash.substring(1), 10));
+        }
+      },
+
+      _navToFile: function(fileList, direction) {
+        if (fileList.length == 0) { return; }
+
+        var idx = fileList.indexOf(this._path) + direction;
+        if (idx < 0 || idx > fileList.length - 1) {
+          page.show(this._computeChangePath(this._changeNum));
+          return;
+        }
+        page.show(this._diffURL(this._changeNum,
+                                this._patchRange.patchNum,
+                                fileList[idx]));
+      },
+
+      _diffURL: function(changeNum, patchNum, path) {
+        return '/c/' + changeNum + '/' + patchNum + '/' + path;
+      },
+
+      _paramsChanged: function(value) {
+        this._changeNum = value.changeNum;
+        this._patchRange = {
+          patchNum: value.patchNum,
+          basePatchNum: value.basePatchNum || 'PARENT',
+        };
+        this._path = value.path;
+
+        // When navigating away from the page, there is a possibility that the
+        // patch number is no longer a part of the URL (say when navigating to
+        // the top-level change info view) and therefore undefined in `params`.
+        if (!this._patchRange.patchNum) {
+          return;
+        }
+      },
+
+      _computeChangePath: function(changeNum) {
+        return '/c/' + changeNum;
+      },
+
+      _computeChangeDetailPath: function(changeNum) {
+        return '/changes/' + changeNum + '/detail';
+      },
+
+      _computeChangeDetailQueryParams: function() {
+        return { O: Changes.listChangesOptionsToHex(
+            Changes.ListChangesOption.ALL_REVISIONS
+        )};
+      },
+
+      _computeFilesPath: function(changeNum, patchNum) {
+        return Changes.baseURL(changeNum, patchNum) + '/files';
+      },
+
+      _handleFilesResponse: function(e, req) {
+        this._fileList = Object.keys(e.detail.response).sort();
+      },
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-diff.html b/polygerrit-ui/app/elements/gr-diff.html
new file mode 100644
index 0000000..23d14b5
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-diff.html
@@ -0,0 +1,394 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="gr-ajax.html">
+<link rel="import" href="gr-diff-side.html">
+<link rel="import" href="gr-request.html">
+
+<dom-module id="gr-diff">
+  <template>
+    <style>
+      :host {
+        border-bottom: 1px solid #eee;
+        border-top: 1px solid #eee;
+        font-family: 'Source Code Pro', monospace;
+        display: flex;
+        white-space: pre;
+      }
+      gr-diff-side:first-of-type {
+        --light-highlight-color: #ffecec;
+        --dark-highlight-color: #faa;
+      }
+      gr-diff-side:last-of-type {
+        --light-highlight-color: #eaffea;
+        --dark-highlight-color: #9f9;
+        border-right: 1px solid #ddd;
+      }
+    </style>
+    <gr-ajax id="diffXHR"
+        url="[[_computeDiffPath(changeNum, patchRange.patchNum, path)]]"
+        params="[[_computeDiffQueryParams(patchRange.basePatchNum)]]"
+        last-response="{{_diffResponse}}"></gr-ajax>
+    <gr-ajax id="baseCommentsXHR"
+        url="[[_computeCommentsPath(changeNum, patchRange.basePatchNum)]]"></gr-ajax>
+    <gr-ajax id="commentsXHR"
+        url="[[_computeCommentsPath(changeNum, patchRange.patchNum)]]"></gr-ajax>
+    <gr-ajax id="baseDraftsXHR"
+        url="[[_computeDraftsPath(changeNum, patchRange.basePatchNum)]]"></gr-ajax>
+    <gr-ajax id="draftsXHR"
+        url="[[_computeDraftsPath(changeNum, patchRange.patchNum)]]"></gr-ajax>
+    <gr-diff-side id="leftDiff"
+        content="{{_diff.leftSide}}"
+        width="[[sideWidth]]"
+        can-comment="[[_loggedIn]]"></gr-diff-side>
+    <gr-diff-side id="rightDiff"
+        content="{{_diff.rightSide}}"
+        width="[[sideWidth]]"
+        can-comment="[[_loggedIn]]"></gr-diff-side>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-diff',
+
+      /**
+       * Fired when the diff is rendered.
+       *
+       * @event render
+       */
+
+      properties: {
+        auto: {
+          type: Boolean,
+          value: false,
+        },
+        changeNum: String,
+        /*
+         * A single object to encompass basePatchNum and patchNum is used
+         * so that both can be set at once without incremental observers
+         * firing after each property changes.
+         */
+        patchRange: Object,
+        path: String,
+        sideWidth: {
+          type: Number,
+          value: 80,
+        },
+        _comments: Array,
+        _baseComments: Array,
+        _drafts: Array,
+        _baseDrafts: Array,
+        _diffResponse: Object,
+        _diff: {
+          type: Object,
+          value: function() { return {}; },
+        },
+        _loggedIn: {
+          type: Boolean,
+          value: false,
+        },
+        _diffRequestsPromise: Object,  // Used for testing.
+      },
+
+      observers: [
+        '_diffOptionsChanged(changeNum, patchRange, path)'
+      ],
+
+      ready: function() {
+        app.accountReady.then(function() {
+          this._loggedIn = app.loggedIn;
+        }.bind(this));
+      },
+
+      scrollToLine: function(lineNum) {
+        // TODO(andybons): Should this always be the right side?
+        this.$.rightDiff.scrollToLine(lineNum);
+      },
+
+      _diffOptionsChanged: function(changeNum, patchRange, path) {
+        if (!this.auto) { return; }
+
+        var promises = [this.$.diffXHR.generateRequest().completes];
+
+        var basePatchNum = patchRange.basePatchNum;
+        var patchNum = patchRange.patchNum;
+
+        app.accountReady.then(function() {
+          promises.push(this._getCommentsAndDrafts(basePatchNum, app.loggedIn));
+          this._diffRequestsPromise = Promise.all(promises).then(function() {
+            this._allDataReceived();
+          }.bind(this)).catch(function(err) {
+            alert('Oops. Something went wrong. Check the console and bug the ' +
+                'PolyGerrit team for assistance.');
+            throw err;
+          });
+        }.bind(this));
+      },
+
+      _allDataReceived: function() {
+        this._processContent();
+
+        // Allow for the initial rendering to complete before firing the event.
+        this.async(function() {
+          this.fire('render', {bubbles: false});
+        }.bind(this), 1);
+      },
+
+      _getCommentsAndDrafts: function(basePatchNum, loggedIn) {
+        var promises = [];
+
+        function onlyParent(c) { return c.side == 'PARENT'; }
+        function withoutParent(c) { return c.side != 'PARENT'; }
+
+        var promises = [];
+        var commentsPromise = this.$.commentsXHR.generateRequest().completes;
+        promises.push(commentsPromise.then(function(req) {
+          var comments = req.response[this.path] || [];
+          if (basePatchNum == 'PARENT') {
+            this._baseComments = comments.filter(onlyParent);
+          }
+          this._comments = comments.filter(withoutParent);
+        }.bind(this)));
+
+        if (basePatchNum != 'PARENT') {
+          commentsPromise = this.$.baseCommentsXHR.generateRequest().completes;
+          promises.push(commentsPromise.then(function(req) {
+            this._baseComments =
+              (req.response[this.path] || []).filter(withoutParent);
+          }.bind(this)));
+        }
+
+        if (!loggedIn) {
+          this._baseDrafts = [];
+          this._drafts = [];
+          return Promise.all(promises);
+        }
+
+        var draftsPromise = this.$.draftsXHR.generateRequest().completes;
+        promises.push(draftsPromise.then(function(req) {
+          var drafts = req.response[this.path] || [];
+          if (basePatchNum == 'PARENT') {
+            this._baseDrafts = drafts.filter(onlyParent);
+          }
+          this._drafts = drafts.filter(withoutParent);
+        }.bind(this)));
+
+        if (basePatchNum != 'PARENT') {
+          draftsPromise = this.$baseDraftsXHR.generateRequest().completes;
+          promises.push(draftsPromise.then(function(req) {
+            this._baseDrafts =
+                (req.response[this.path] || []).filter(withoutParent);
+          }.bind(this)));
+        }
+
+        return Promise.all(promises);
+      },
+
+      _computeDiffPath: function(changeNum, patchNum, path) {
+        return Changes.baseURL(changeNum, patchNum) + '/files/' +
+            encodeURIComponent(path) + '/diff';
+      },
+
+      _computeCommentsPath: function(changeNum, patchNum) {
+        return Changes.baseURL(changeNum, patchNum) + '/comments';
+      },
+
+      _computeDraftsPath: function(changeNum, patchNum) {
+        return Changes.baseURL(changeNum, patchNum) + '/drafts';
+      },
+
+      _computeDiffQueryParams: function(basePatchNum) {
+        var params =  {
+          context: 'ALL',
+          intraline: null
+        };
+        if (basePatchNum != 'PARENT') {
+          params.base = basePatchNum;
+        }
+        return params;
+      },
+
+      _processContent: function() {
+        var leftSide = [];
+        var rightSide = [];
+        var initialLineNum = 0 + (this._diffResponse.content.skip || 0);
+        var ctx = {
+          left: {
+            lineNum: initialLineNum,
+          },
+          right: {
+            lineNum: initialLineNum,
+          }
+        };
+        for (var i = 0; i < this._diffResponse.content.length; i++) {
+          this._addDiffChunk(ctx, this._diffResponse.content[i], leftSide,
+              rightSide);
+        }
+        this._diff = {
+          leftSide: leftSide,
+          rightSide: rightSide,
+        };
+      },
+
+      _addDiffChunk: function(ctx, chunk, leftSide, rightSide) {
+        if (chunk.ab) {
+          for (var i = 0; i < chunk.ab.length; i++) {
+            var numLines = Math.ceil(chunk.ab[i].length / this.sideWidth);
+            // Blank lines within a diff content array indicate a newline.
+            leftSide.push({
+              type: 'CODE',
+              content: chunk.ab[i] || '\n',
+              numLines: numLines,
+              lineNum: ++ctx.left.lineNum,
+            });
+            rightSide.push({
+              type: 'CODE',
+              content: chunk.ab[i] || '\n',
+              numLines: numLines,
+              lineNum: ++ctx.right.lineNum,
+            });
+          }
+        }
+
+        var leftHighlights = [];
+        if (chunk.edit_a) {
+          leftHighlights =
+            this._normalizeIntralineHighlights(chunk.a, chunk.edit_a);
+        }
+        var rightHighlights = [];
+        if (chunk.edit_b) {
+          rightHighlights =
+              this._normalizeIntralineHighlights(chunk.b, chunk.edit_b);
+        }
+
+        var aLen = (chunk.a && chunk.a.length) || 0;
+        var bLen = (chunk.b && chunk.b.length) || 0;
+        var maxLen = Math.max(aLen, bLen);
+        for (var i = 0; i < maxLen; i++) {
+          var hasLeftContent = chunk.a && i < chunk.a.length;
+          var hasRightContent = chunk.b && i < chunk.b.length;
+          var leftContent = hasLeftContent ? chunk.a[i] : '';
+          var rightContent = hasRightContent ? chunk.b[i] : '';
+          var maxNumLines = this._maxLinesSpanned(leftContent, rightContent);
+          if (hasLeftContent) {
+            leftSide.push({
+              type: 'CODE',
+              content: leftContent || '\n',
+              numLines: maxNumLines,
+              lineNum: ++ctx.left.lineNum,
+              highlight: true,
+              intraline: leftHighlights.filter(function(hl) {
+                return hl.contentIndex == i;
+              }),
+            });
+          } else {
+            leftSide.push({
+              type: 'FILLER',
+              numLines: maxNumLines,
+            });
+          }
+          if (hasRightContent) {
+            rightSide.push({
+              type: 'CODE',
+              content: rightContent || '\n',
+              numLines: maxNumLines,
+              lineNum: ++ctx.right.lineNum,
+              highlight: true,
+              intraline: rightHighlights.filter(function(hl) {
+                return hl.contentIndex == i;
+              }),
+            });
+          } else {
+            rightSide.push({
+              type: 'FILLER',
+              numLines: maxNumLines,
+            });
+          }
+        }
+      },
+
+
+      // The `highlights` array consists of a list of <skip length, mark length>
+      // pairs, where the skip length is the number of characters between the
+      // end of the previous edit and the start of this edit, and the mark
+      // length is the number of edited characters following the skip. The start
+      // of the edits is from the beginning of the related diff content lines.
+      //
+      // Note that the implied newline character at the end of each line is
+      // included in the length calculation, and thus it is possible for the
+      // edits to span newlines.
+      //
+      // A line highlight object consists of three fields:
+      // - contentIndex: The index of the diffChunk `content` field (the line
+      //   being referred to).
+      // - startIndex: Where the highlight should begin.
+      // - endIndex: (optional) Where the highlight should end. If omitted, the
+      //   highlight is meant to be a continuation onto the next line.
+      _normalizeIntralineHighlights: function(content, highlights) {
+        var contentIndex = 0;
+        var idx = 0;
+        var normalized = [];
+        for (var i = 0; i < highlights.length; i++) {
+          var line = content[contentIndex] + '\n';
+          var hl = highlights[i];
+          var j = 0;
+          while (j < hl[0]) {
+            if (idx == line.length) {
+              idx = 0;
+              line = content[++contentIndex] + '\n';
+              continue;
+            }
+            idx++;
+            j++;
+          }
+          var lineHighlight = {
+            contentIndex: contentIndex,
+            startIndex: idx,
+          };
+
+          j = 0;
+          while (line && j < hl[1]) {
+            if (idx == line.length) {
+              idx = 0;
+              line = content[++contentIndex] + '\n';
+              normalized.push(lineHighlight);
+              lineHighlight = {
+                contentIndex: contentIndex,
+                startIndex: idx,
+              };
+              continue;
+            }
+            idx++;
+            j++;
+          }
+          lineHighlight.endIndex = idx;
+          normalized.push(lineHighlight);
+        }
+        return normalized;
+      },
+
+      _maxLinesSpanned: function(left, right) {
+        return Math.max(Math.ceil(left.length / this.sideWidth),
+                        Math.ceil(right.length / this.sideWidth));
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-file-list.html b/polygerrit-ui/app/elements/gr-file-list.html
new file mode 100644
index 0000000..dcc96ac
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-file-list.html
@@ -0,0 +1,161 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="gr-ajax.html">
+
+<dom-module id="gr-file-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      .tableContainer {
+        overflow-x: auto;
+      }
+      table {
+        border-collapse: collapse;
+        width: 100%;
+      }
+      table a {
+        display: block;
+      }
+      td {
+        padding: 2px 0;
+        white-space: nowrap;
+      }
+      th {
+        text-align: left;
+      }
+      .status {
+        width: 20px;
+      }
+      .drafts {
+        color: #C62828;
+        font-weight: bold;
+      }
+    </style>
+    <gr-ajax id="filesXHR"
+        url="[[_computeFilesURL(changeNum, revision)]]"
+        on-response="_handleResponse"></gr-ajax>
+    <gr-ajax id="draftsXHR"
+        url="[[_computeDraftsURL(changeNum, revision)]]"
+        last-response="{{_drafts}}"></gr-ajax>
+    </gr-ajax>
+    <div class="tableContainer">
+      <table>
+        <tr>
+          <th></th>
+          <th></th>
+          <th>Path</th>
+          <th>Comments</th>
+          <th>Stats</th>
+        </tr>
+        <template is="dom-repeat" items="{{files}}" as="file">
+          <tr>
+            <td></td>
+            <td class="status">[[file.status]]</td>
+            <td class="path">
+              <a class="file"
+                 href$="[[_computeDiffURL(changeNum, patchNum, file.__path)]]">[[file.__path]]</a>
+            </td>
+            <td>
+              <span class="drafts">[[_computeDraftsString(_drafts, file.__path)]]</span>
+              <span class="comments">[[_computeCommentsString(comments, file.__path)]]</span>
+            </td>
+            <td>
+              +<span>[[file.lines_inserted]]</span> lines,
+              -<span>[[file.lines_deleted]]</span> lines
+            </td>
+          </tr>
+        </template>
+      </table>
+    </div>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-file-list',
+
+      properties: {
+        patchNum: Number,
+        changeNum: Number,
+        revision: String,
+        comments: Object,
+
+        _drafts: Object,
+      },
+
+      observers: [
+        '_changeNumOrRevisionChanged(changeNum, revision)',
+      ],
+
+      reload: function() {
+        if (!!this.changeNum && !!this.revision) {
+          this.$.filesXHR.generateRequest();
+          this.$.draftsXHR.generateRequest();
+        }
+      },
+
+      _changeNumOrRevisionChanged: function(changeNum, revision) {
+        this.reload();
+      },
+
+      _computeFilesURL: function(changeNum, revision) {
+        return Changes.baseURL(changeNum, revision) + '/files';
+      },
+
+      _computeCommentsString: function(comments, path) {
+        var num = (comments[path] || []).length;
+        if (num == 0) { return ''; }
+        if (num == 1) { return '1 comment'; }
+        if (num > 1) { return num + ' comments'; };
+      },
+
+      _computeDraftsURL: function(changeNum, revision) {
+        return Changes.baseURL(changeNum, revision) + '/drafts';
+      },
+
+      _computeDraftsString: function(drafts, path) {
+        var num = (drafts[path] || []).length;
+        if (num == 0) { return ''; }
+        if (num == 1) { return '1 draft'; }
+        if (num > 1) { return num + ' drafts'; };
+      },
+
+      _handleResponse: function(e, req) {
+        var result = e.detail.response;
+        var paths = Object.keys(result).sort();
+        var files = [];
+        for (var i = 0; i < paths.length; i++) {
+          var info = result[paths[i]];
+          info.__path = paths[i];
+          info.lines_inserted = info.lines_inserted || 0;
+          info.lines_deleted = info.lines_deleted || 0;
+          files.push(info)
+        }
+        this.files = files;
+      },
+
+      _computeDiffURL: function(changeNum, patchNum, path) {
+        return '/c/' + changeNum + '/' + patchNum + '/' + path;
+      },
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-message.html b/polygerrit-ui/app/elements/gr-message.html
new file mode 100644
index 0000000..8e0af05
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-message.html
@@ -0,0 +1,182 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="gr-comment-list.html">
+<link rel="import" href="gr-date-formatter.html">
+
+<dom-module id="gr-message">
+  <template>
+    <style>
+      :host {
+        border-top: 1px solid #ddd;
+        display: block;
+        position: relative;
+      }
+      :host(:not([expanded])) {
+        cursor: pointer;
+      }
+      .avatar {
+        border-radius: 50%;
+        position: absolute;
+        left: var(--default-horizontal-margin);
+      }
+      :not(.hideAvatar):not(.showAvatar) .avatar,
+      .hideAvatar .avatar {
+        display: none;
+      }
+      .collapsed .contentContainer {
+        color: #777;
+        white-space: nowrap;
+        overflow-x: hidden;
+        text-overflow: ellipsis;
+      }
+      .showAvatar.expanded .contentContainer {
+        margin-left: calc(var(--default-horizontal-margin) + 2.5em);
+        padding: 10px 0;
+      }
+      .showAvatar.collapsed .contentContainer {
+        margin-left: calc(var(--default-horizontal-margin) + 1.75em);
+        padding: 10px 75px 10px 0;
+      }
+      .hideAvatar.collapsed .contentContainer,
+      .hideAvatar.expanded .contentContainer {
+        margin-left: 0;
+        padding: 10px 75px 10px 0;
+      }
+      .collapsed .avatar {
+        top: 8px;
+      }
+      .expanded .avatar {
+        top: 12px;
+      }
+      .collapsed .avatar {
+        height: 1.75em;
+        width: 1.75em;
+      }
+      .expanded .avatar {
+        height: 2.5em;
+        width: 2.5em;
+      }
+      .name {
+        font-weight: bold;
+      }
+      .collapsed .name,
+      .collapsed .content,
+      .collapsed .message {
+        display: inline;
+      }
+      .collapsed gr-comment-list {
+        display: none;
+      }
+      .collapsed .name,
+      .collapsed gr-date-formatter {
+        color: var(--default-text-color);
+      }
+      .expanded .name {
+        cursor: pointer;
+      }
+      .expanded .message {
+        white-space: pre-wrap;
+      }
+      gr-date-formatter {
+        position: absolute;
+        right: var(--default-horizontal-margin);
+        top: 10px;
+      }
+    </style>
+    <div class$="[[_computeClass(expanded, showAvatar)]]">
+      <img class="avatar" src$="[[_computeAvatarURL(message.author, showAvatar)]]">
+      <div class="contentContainer">
+        <div class="name" id="name">[[message.author.name]]</div>
+        <div class="content">
+          <div class="message">[[message.message]]</div>
+          <gr-comment-list
+              comments="[[comments]]"
+              change-num="[[changeNum]]"
+              patch-num="[[message._revision_number]]"></gr-comment-list>
+        </div>
+        <gr-date-formatter date-str="[[message.date]]"></gr-date-formatter>
+      </div>
+    </div>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-message',
+
+      listeners: {
+        'tap': '_tapHandler',
+        'name.tap': '_collapseHandler',
+      },
+
+      properties: {
+        changeNum: Number,
+        message: Object,
+        comments: {
+          type: Object,
+          observer: '_commentsChanged',
+        },
+        expanded: {
+          type: Boolean,
+          value: true,
+          reflectToAttribute: true,
+        },
+        showAvatar: {
+          type: Boolean,
+          value: false,
+        },
+      },
+
+      ready: function() {
+        app.configReady.then(function(cfg) {
+          this.showAvatar = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+        }.bind(this));
+      },
+
+      _commentsChanged: function(value) {
+        this.expanded = Object.keys(value || {}).length > 0;
+      },
+
+      _tapHandler: function(e) {
+        if (this.expanded) { return; }
+        this.expanded = true;
+      },
+
+      _collapseHandler: function(e) {
+        if (!this.expanded) { return; }
+        e.stopPropagation();
+        this.expanded = false;
+      },
+
+      _computeClass: function(expanded, showAvatar) {
+        var classes = [];
+        classes.push(expanded ? 'expanded' : 'collapsed');
+        classes.push(showAvatar ? 'showAvatar' : 'hideAvatar');
+        return classes.join(' ');
+      },
+
+      _computeAvatarURL: function(author, showAvatar) {
+        if (!showAvatar || !author) { return '' }
+        return '/accounts/' + author.email + '/avatar?s=100';
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-messages-list.html b/polygerrit-ui/app/elements/gr-messages-list.html
new file mode 100644
index 0000000..9d6df66
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-messages-list.html
@@ -0,0 +1,85 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="gr-message.html">
+
+<dom-module id="gr-messages-list">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      h3 {
+        margin-bottom: .35em;
+      }
+      h3,
+      gr-message {
+        padding: 0 var(--default-horizontal-margin);
+      }
+    </style>
+    <h3>Messages</h3>
+    <template is="dom-repeat" items="{{messages}}" as="message">
+      <gr-message change-num="[[changeNum]]"
+                  message="[[message]]"
+                  comments="[[_computeCommentsForMessage(comments, message, index)]]"></gr-message>
+    </template>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-messages-list',
+
+      properties: {
+        changeNum: Number,
+        messages: {
+          type: Array,
+          value: function() { return []; },
+        },
+        comments: Object,
+      },
+
+      _computeCommentsForMessage: function(comments, message, index) {
+        var comments = comments || {};
+        var messages = this.messages || [];
+        var msgComments = {};
+        var mDate = util.parseDate(message.date);
+        var nextMDate;
+        if (index < messages.length - 1) {
+          nextMDate = util.parseDate(messages[index + 1].date);
+        }
+        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) {
+                continue;
+              }
+              msgComments[file] = msgComments[file] || [];
+              msgComments[file].push(fileComments[i]);
+            }
+          }
+        }
+        return msgComments;
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-reply-dropdown.html b/polygerrit-ui/app/elements/gr-reply-dropdown.html
new file mode 100644
index 0000000..dd607af
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-reply-dropdown.html
@@ -0,0 +1,308 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../bower_components/iron-selector/iron-selector.html">
+<link rel="import" href="gr-ajax.html">
+<link rel="import" href="gr-request.html">
+
+<dom-module id="gr-reply-dropdown">
+  <style>
+    :host {
+      display: inline-block;
+    }
+    :host([disabled]) {
+        pointer-events: none;
+    }
+    :host([disabled]) .dropdown-content {
+      opacity: .5;
+    }
+    .dropdown-content {
+      background-color: #fff;
+      box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+    }
+    button {
+      font: inherit;
+      background-color: #f1f2f3;
+      border: 1px solid #aaa;
+      border-radius: 2px;
+      padding: .2em .5em;
+    }
+    section {
+      border-top: 1px solid #ddd;
+      padding: .5em .75em;
+    }
+    .textareaContainer {
+      position: relative;
+    }
+    .message {
+      border: none;
+    }
+    .label:first-of-type {
+      margin-bottom: .25em;
+    }
+    .labelName {
+      display: inline-block;
+      text-align: right;
+      width: 6em;
+      margin-right: .5em;
+    }
+    iron-selector {
+      display: inline-flex;
+    }
+    iron-selector > button {
+      background-color: #fff;
+      border: 1px solid #ddd;
+      border-left: none;
+      padding: .25em 0;
+      cursor: pointer;
+      width: 3em;
+      text-align: center;
+    }
+    iron-selector > button:first-of-type {
+      border-top-left-radius: 2px;
+      border-bottom-left-radius: 2px;
+      border-left: 1px solid #ddd;
+    }
+    iron-selector > button:last-of-type {
+      border-top-right-radius: 2px;
+      border-bottom-right-radius: 2px;
+    }
+    iron-selector > button.iron-selected {
+      background-color: #ddd;
+    }
+    .draftsContainer h3 {
+      margin-top: .25em;
+    }
+    .actionsContainer {
+      display: flex;
+    }
+    .action:link,
+    .action:visited {
+      color: #00e;
+    }
+    .danger {
+      display: flex;
+      flex: 1;
+      justify-content: flex-end;
+    }
+  </style>
+  <template>
+    <gr-ajax id="draftsXHR"
+        url="[[_computeDraftsURL(changeNum)]]"
+        last-response="{{_drafts}}"></gr-ajax>
+    <button id="trigger" on-tap="_showPopupTapHandler">Reply</button>
+    <iron-dropdown id="dropdown"
+        vertical-align="top"
+        vertical-offset="25"
+        horizontal-align="right">
+      <div class="dropdown-content">
+        <section class="textareaContainer">
+          <iron-autogrow-textarea
+              id="textarea"
+              class="message"
+              placeholder="Say something..."
+              disabled="{{disabled}}"
+              rows="1"
+              bind-value="{{_draft}}"></iron-autogrow-textarea>
+        </section>
+        <section>
+          <template is="dom-repeat"
+              items="[[_computeLabelArray(permittedLabels)]]" as="label">
+            <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">
+                <button data-value$="[[value]]">[[value]]</button>
+              </template>
+            </iron-selector>
+          </template>
+        </section>
+        <section class="draftsContainer" hidden$="[[_computeHideDraftList(_drafts)]]">
+          <h3>[[_computeDraftsTitle(_drafts)]]</h3>
+          <gr-comment-list
+              comments="[[_drafts]]"
+              change-num="[[changeNum]]"
+              patch-num="[[patchNum]]"></gr-comment-list>
+        </section>
+        <section class="actionsContainer">
+          <a class="action send" href="#" on-tap="_sendTapHandler">Send</a>
+          <div class="danger">
+            <a class="action cancel" href="#" on-tap="_cancelTapHandler">Cancel</a>
+          </div>
+        </section>
+      </div>
+    </iron-dropdown>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-reply-dropdown',
+
+      /**
+       * Fired when a reply is successfully sent.
+       *
+       * @event send
+       */
+
+      properties: {
+        changeNum: String,
+        patchNum: String,
+        disabled: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        labels: Object,
+        permittedLabels: Object,
+
+        _account: Object,
+        _drafts: Object,
+        _xhrPromise: Object,  // Used for testing.
+        _draft: String,
+      },
+
+      get opened() {
+        return this.$.dropdown.opened;
+      },
+
+      ready: function() {
+        app.accountReady.then(function() {
+          this._account = app.account;
+        }.bind(this));
+      },
+
+      open: function() {
+        this.$.draftsXHR.generateRequest();
+        this.$.dropdown.open();
+        this.async(function() {
+          this.$.textarea.textarea.focus();
+        }.bind(this));
+      },
+
+      close: function() {
+        this._drafts = null;
+        this.$.dropdown.close();
+      },
+
+      _computeDraftsURL: function(changeNum) {
+        return '/changes/' + changeNum + '/drafts';
+      },
+
+      _computeHideDraftList: function(drafts) {
+        return Object.keys(drafts || {}).length == 0;
+      },
+
+      _computeDraftsTitle: function(drafts) {
+        var total = 0;
+        for (var file in drafts) {
+          total += drafts[file].length;
+        }
+        if (total == 0) { return ''; }
+        if (total == 1) { return '1 Draft'; }
+        if (total > 1) { return total + ' Drafts'; };
+      },
+
+      _computeLabelArray: function(labelsObj) {
+        return Object.keys(labelsObj).sort();
+      },
+
+      _computeIndexOfLabelValue: function(
+          labels, permittedLabels, labelName, account) {
+        var labelValue = labels[labelName].default_value;
+
+        // Is there an existing vote for the current user? If so, use that.
+        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;
+            }
+          }
+        }
+
+        for (var i = 0; i < permittedLabels[labelName].length; i++) {
+          var val = parseInt(permittedLabels[labelName][i], 10);
+          if (val == labelValue) {
+            return i;
+          }
+        }
+        return null;
+      },
+
+      _computePermittedLabelValues: function(permittedLabels, label) {
+        return permittedLabels[label];
+      },
+
+      _showPopupTapHandler: function(e) {
+        e.preventDefault();
+        this.open();
+      },
+
+      _cancelTapHandler: function(e) {
+        e.preventDefault();
+        this.$.dropdown.close();
+      },
+
+      _sendTapHandler: function(e) {
+        e.preventDefault();
+        var obj = {
+          drafts: 'PUBLISH_ALL_REVISIONS',
+          labels: {},
+        };
+        for (var label in this.permittedLabels) {
+          var selectorEl = this.$$('iron-selector[data-label="' + label + '"]');
+          var selectedVal = selectorEl.selectedItem.getAttribute('data-value');
+          selectedVal = parseInt(selectedVal, 10);
+          obj.labels[label] = selectedVal;
+        }
+        if (this._draft != null) {
+          obj.message = this._draft;
+        }
+        this.disabled = true;
+        this._send(obj).then(function(req) {
+          this.fire('send', {bubbles: false});
+          this._draft = '';
+          this.disabled = false;
+          this.$.dropdown.close();
+        }.bind(this)).catch(function(err) {
+          alert('Oops. Something went wrong. Check the console and bug the ' +
+              'PolyGerrit team for assistance.');
+          throw err;
+        }.bind(this));
+      },
+
+      _send: function(payload) {
+        var xhr = document.createElement('gr-request');
+        this._xhrPromise = xhr.send({
+          method: 'POST',
+          url: Changes.baseURL(this.changeNum, this.patchNum) + '/review',
+          body: payload,
+        });
+
+        return this._xhrPromise;
+      },
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-request.html b/polygerrit-ui/app/elements/gr-request.html
new file mode 100644
index 0000000..4e161445a
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-request.html
@@ -0,0 +1,47 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../bower_components/iron-ajax/iron-request.html">
+
+<dom-module id="gr-request">
+  <template>
+    <iron-request id="xhr"></iron-request>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-request',
+
+      hostAttributes: {
+        hidden: true
+      },
+
+      send: function(options) {
+        options.headers = options.headers || {};
+        options.headers['content-type'] =
+            options.headers['content-type'] || 'application/json';
+        options.headers['x-gerrit-auth'] = options.headers['x-gerrit-auth'] ||
+            util.getCookie('XSRF_TOKEN');
+        options.jsonPrefix = options.jsonPrefix || ')]}\'';
+        return this.$.xhr.send(options);
+      },
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-search-bar.html b/polygerrit-ui/app/elements/gr-search-bar.html
new file mode 100644
index 0000000..d9ad1a3
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-search-bar.html
@@ -0,0 +1,93 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../bower_components/iron-input/iron-input.html">
+
+<dom-module id="gr-search-bar">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      form {
+        display: flex;
+        margin-left: 3em;
+      }
+      input,
+      button {
+        border: 1px solid #aaa;
+        font: inherit;
+        padding: .2em .5em;
+      }
+      input {
+        flex: 1;
+        border-radius: 2px 0 0 2px;
+      }
+      button {
+        background-color: #f1f2f3;
+        border-radius: 0 2px 2px 0;
+        border-left-width: 0;
+      }
+    </style>
+    <form>
+      <input is="iron-input" id="searchInput" bind-value="{{_inputVal}}">
+      <button type="submit" id="searchButton">Search</button>
+    </form>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-search-bar',
+
+      listeners: {
+        'searchInput.keydown': '_inputKeyDownHandler',
+        'searchButton.tap': '_preventDefaultAndNavigateToInputVal',
+      },
+
+      properties: {
+        value: {
+          type: String,
+          value: '',
+          notify: true,
+          observer: '_valueChanged',
+        },
+        _inputVal: String,
+      },
+
+      _valueChanged: function(value) {
+        this._inputVal = value;
+      },
+
+      _inputKeyDownHandler: function(e) {
+        if (e.keyCode == 13) {
+          // Enter was pressed.
+          this._preventDefaultAndNavigateToInputVal(e);
+        }
+      },
+
+      _preventDefaultAndNavigateToInputVal: function(e) {
+        e.preventDefault();
+        page.show('/q/' + this._inputVal);
+      },
+
+    });
+
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/favicon.ico b/polygerrit-ui/app/favicon.ico
new file mode 100644
index 0000000..155217b
--- /dev/null
+++ b/polygerrit-ui/app/favicon.ico
Binary files differ
diff --git a/polygerrit-ui/app/index.html b/polygerrit-ui/app/index.html
new file mode 100644
index 0000000..0e4f281
--- /dev/null
+++ b/polygerrit-ui/app/index.html
@@ -0,0 +1,29 @@
+<!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.
+-->
+
+<html lang="en">
+<meta charset="utf-8">
+<meta name="description" content="Gerrit Code Review">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>PolyGerrit</title>
+
+<link rel="stylesheet" href="/styles/main.css">
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="import" href="/elements/gr-app.html">
+
+<body unresolved>
+<gr-app id="app"></gr-app>
diff --git a/polygerrit-ui/app/polygerrit_wct_tests.py b/polygerrit-ui/app/polygerrit_wct_tests.py
new file mode 100644
index 0000000..571dcb8
--- /dev/null
+++ b/polygerrit-ui/app/polygerrit_wct_tests.py
@@ -0,0 +1,105 @@
+# 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.
+
+from __future__ import print_function
+
+import atexit
+from distutils import spawn
+import json
+import os
+import pkg_resources
+import shlex
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+import zipfile
+
+
+def _write_wct_conf(root, exports):
+  with open(os.path.join(root, 'wct.conf.js'), 'w') as f:
+    f.write('module.exports = %s;\n' % json.dumps(exports))
+
+
+def _wct_cmd():
+  return ['wct'] + shlex.split(os.environ.get('WCT_ARGS', ''))
+
+
+class PolyGerritWctTests(unittest.TestCase):
+
+  # Should really be setUpClass/tearDownClass, but Buck's test runner doesn't
+  # produce sane stack traces from those methods. There's only one test method
+  # anyway, so just use setUp.
+
+  def _check_wct(self):
+    self.assertTrue(
+        spawn.find_executable('wct'),
+        msg='wct not found; try `npm install -g web-component-tester`')
+
+  def _extract_resources(self):
+    tmpdir = tempfile.mkdtemp()
+    atexit.register(lambda: shutil.rmtree(tmpdir))
+    root = os.path.join(tmpdir, 'polygerrit')
+    os.mkdir(root)
+
+    tr = 'test_resources.zip'
+    zip_path = os.path.join(tmpdir, tr)
+    s = pkg_resources.resource_stream(__name__, tr)
+    with open(zip_path, 'w') as f:
+      shutil.copyfileobj(s, f)
+
+    with zipfile.ZipFile(zip_path, 'r') as z:
+      z.extractall(root)
+
+    return tmpdir, root
+
+  def test_wct(self):
+    self._check_wct()
+    tmpdir, root = self._extract_resources()
+
+    cmd = _wct_cmd()
+    print('Running %s in %s' % (cmd, root), file=sys.stderr)
+
+    _write_wct_conf(root, {
+      'suites': ['test'],
+      'webserver': {
+        'pathMappings': [
+          {'/components/bower_components': 'bower_components'},
+        ],
+      },
+      'plugins': {
+        'local': {
+          # For some reason wct tries to install selenium into its node_modules
+          # directory on first run. If you've installed into /usr/local and
+          # aren't running wct as root, you're screwed. Turning this option off
+          # seems to still work, so there's that.
+          'skipSeleniumInstall': True,
+        },
+      },
+    })
+
+    p = subprocess.Popen(cmd, cwd=root,
+                         stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    out, err = p.communicate()
+    sys.stdout.write(out)
+    sys.stderr.write(err)
+    self.assertEquals(0, p.returncode)
+
+    # Only remove tmpdir if successful, to allow debugging.
+    shutil.rmtree(tmpdir)
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/polygerrit-ui/app/robots.txt b/polygerrit-ui/app/robots.txt
new file mode 100644
index 0000000..eb05362
--- /dev/null
+++ b/polygerrit-ui/app/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow:
diff --git a/polygerrit-ui/app/scripts/app.js b/polygerrit-ui/app/scripts/app.js
new file mode 100644
index 0000000..3ceb90d
--- /dev/null
+++ b/polygerrit-ui/app/scripts/app.js
@@ -0,0 +1,91 @@
+// 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.
+
+'use strict';
+
+// 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');
+
+window.addEventListener('WebComponentsReady', function() {
+  // Middleware
+  page(function(ctx, next) {
+    document.body.scrollTop = 0;
+    next();
+  });
+
+  function loadUser(ctx, next) {
+    app.accountReady.then(function() {
+      next();
+    });
+  }
+
+  // Routes.
+  page('/', loadUser, function() {
+    if (app.loggedIn) {
+      page.redirect('/dashboard/self');
+    } else {
+      page.redirect('/q/status:open');
+    }
+  });
+
+  page('/dashboard/(.*)', loadUser, function(data) {
+    if (app.loggedIn) {
+      app.route = 'gr-dashboard-view';
+      app.params = data.params;
+    } else {
+      page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
+    }
+  });
+
+  function queryHandler(data) {
+    app.route = 'gr-change-list-view';
+    app.params = data.params;
+  }
+
+  page('/q/:query,:offset', queryHandler);
+  page('/q/:query', queryHandler);
+
+  page(/^\/(\d+)\/?/, function(ctx) {
+    page.redirect('/c/' + ctx.params[0]);
+  });
+
+  page('/c/:changeNum', function(data) {
+    app.route = 'gr-change-view';
+    app.params = data.params;
+  });
+
+  page(/^\/c\/(\d+)\/((\d+)(\.\.(\d+))?)\/(.+)/, function(ctx) {
+    app.route = 'gr-diff-view';
+    var params = {
+      changeNum: ctx.params[0],
+      basePatchNum: ctx.params[2],
+      patchNum: ctx.params[4],
+      path: ctx.params[5]
+    };
+    // Don't allow diffing the same patch number against itself because WHY?
+    if (params.basePatchNum == params.patchNum) {
+      page.redirect('/c/' + params.changeNum + '/' + params.patchNum + '/' +
+          params.path);
+      return;
+    }
+    if (!params.patchNum) {
+      params.patchNum = params.basePatchNum;
+      delete(params.basePatchNum);
+    }
+    app.params = params;
+  });
+
+  page.start();
+});
diff --git a/polygerrit-ui/app/scripts/changes.js b/polygerrit-ui/app/scripts/changes.js
new file mode 100644
index 0000000..17c5eb0
--- /dev/null
+++ b/polygerrit-ui/app/scripts/changes.js
@@ -0,0 +1,93 @@
+// 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.
+
+'use strict';
+
+var Changes = Changes || {};
+
+Changes.DiffType = {
+  ADDED: 'ADDED',
+  COPIED: 'COPIED',
+  DELETED: 'DELETED',
+  MODIFIED: 'MODIFIED',
+  RENAMED: 'RENAMED',
+  REWRITE: 'REWRITE',
+};
+
+Changes.Status = {
+  NEW: 'NEW',
+  MERGED: 'MERGED',
+  ABANDONED: 'ABANDONED',
+  DRAFT: 'DRAFT',
+};
+
+// Must be kept in sync with the ListChangesOption enum and protobuf.
+Changes.ListChangesOption = {
+  LABELS: 0,
+  DETAILED_LABELS: 8,
+
+  // Return information on the current patch set of the change.
+  CURRENT_REVISION: 1,
+  ALL_REVISIONS: 2,
+
+  // If revisions are included, parse the commit object.
+  CURRENT_COMMIT: 3,
+  ALL_COMMITS: 4,
+
+  // If a patch set is included, include the files of the patch set.
+  CURRENT_FILES: 5,
+  ALL_FILES: 6,
+
+  // If accounts are included, include detailed account info.
+  DETAILED_ACCOUNTS: 7,
+
+  // Include messages associated with the change.
+  MESSAGES: 9,
+
+  // Include allowed actions client could perform.
+  CURRENT_ACTIONS: 10,
+
+  // Set the reviewed boolean for the caller.
+  REVIEWED: 11,
+
+  // Include download commands for the caller.
+  DOWNLOAD_COMMANDS: 13,
+
+  // Include patch set weblinks.
+  WEB_LINKS: 14,
+
+  // Include consistency check results.
+  CHECK: 15,
+
+  // Include allowed change actions client could perform.
+  CHANGE_ACTIONS: 16,
+
+  // Include a copy of commit messages including review footers.
+  COMMIT_FOOTERS: 17,
+
+  // Include push certificate information along with any patch sets.
+  PUSH_CERTIFICATES: 18
+};
+
+Changes.listChangesOptionsToHex = function() {
+  var v = 0;
+  for (var i = 0; i < arguments.length; i++) {
+    v |= 1 << arguments[i];
+  }
+  return v.toString(16);
+};
+
+Changes.baseURL = function(changeNum, patchNum) {
+  return '/changes/' + changeNum + '/revisions/' + patchNum;
+};
diff --git a/polygerrit-ui/app/scripts/fake-app.js b/polygerrit-ui/app/scripts/fake-app.js
new file mode 100644
index 0000000..87e2d04
--- /dev/null
+++ b/polygerrit-ui/app/scripts/fake-app.js
@@ -0,0 +1,28 @@
+// 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.
+
+'use strict';
+
+/**
+ * A stub of the global gr-app element. Use this for testing.
+ */
+var app = {
+  accountReady: {
+    then: function(cb) { cb(); },
+  },
+  configReady: {
+    then: function(cb) { cb(); },
+  },
+  loggedIn: false,
+};
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
new file mode 100644
index 0000000..93a0349
--- /dev/null
+++ b/polygerrit-ui/app/scripts/util.js
@@ -0,0 +1,64 @@
+// 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.
+
+'use strict';
+
+var util = util || {};
+
+util.parseDate = function(dateStr) {
+  // Timestamps are given in UTC and have the format
+  // "'yyyy-mm-dd hh:mm:ss.fffffffff'" where "'ffffffffff'" represents
+  // nanoseconds.
+  // Munge the date into an ISO 8061 format and parse that.
+  return new Date(dateStr.replace(' ', 'T') + 'Z');
+};
+
+util.htmlEntityMap = {
+  '&': '&amp;',
+  '<': '&lt;',
+  '>': '&gt;',
+  '"': '&quot;',
+  '\'': '&#39;',
+  '/': '&#x2F;'
+};
+
+util.escapeHTML = function(str) {
+  return str.replace(/[&<>"'\/]/g, function(s) {
+    return util.htmlEntityMap[s];
+  });
+};
+
+util.shouldSupressKeyboardShortcut = function(e) {
+  var target = e.detail.keyboardEvent.target;
+  return target.tagName == 'INPUT' ||
+         target.tagName == 'TEXTAREA' ||
+         target.tagName == 'SELECT' ||
+         target.tagName == 'BUTTON' ||
+         target.tagName == 'A';
+};
+
+util.getCookie = function(name) {
+  var key = name + '=';
+  var cookies = document.cookie.split(';');
+  for(var i = 0; i < cookies.length; i++) {
+    var c = cookies[i];
+    while (c.charAt(0) == ' ') {
+      c = c.substring(1);
+    }
+    if (c.indexOf(key) == 0) {
+      return c.substring(key.length, c.length);
+    }
+  }
+  return '';
+};
diff --git a/polygerrit-ui/app/styles/app-theme.html b/polygerrit-ui/app/styles/app-theme.html
new file mode 100644
index 0000000..b25682a
--- /dev/null
+++ b/polygerrit-ui/app/styles/app-theme.html
@@ -0,0 +1,27 @@
+<!--
+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.
+-->
+<style is="custom-style">
+:root {
+  --primary-color: #fff;
+  --primary-text-color: #000;
+  --search-border-color: #ddd;
+  --secondary-color: #f1f2f3;
+  --default-text-color: #000;
+  --view-background-color: #fff;
+  --default-horizontal-margin: 1.25rem;
+  --max-constrained-width: 980px;
+}
+</style>
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
new file mode 100644
index 0000000..9b44d31
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -0,0 +1,63 @@
+<!--
+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.
+-->
+<dom-module id="gr-change-list-styles">
+  <template>
+    <style>
+      .keyboard {
+        width: 2em;
+      }
+      .subject {
+        flex-grow: 1;
+        flex-shrink: 1;
+        word-break: break-word;
+      }
+      .status {
+        width: 9em;
+      }
+      .owner {
+        width: 15em;
+      }
+      .project,
+      .branch {
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+      .project {
+        width: 10em;
+      }
+      .branch {
+        width: 7em;
+      }
+      .updated {
+        width: 6em;
+        text-align: right;
+      }
+      .size {
+        width: 9em;
+        text-align: right;
+      }
+      .codeReview {
+        width: 2.6em;
+        text-align: center;
+      }
+      .verified {
+        width: 2em;
+        text-align: center;
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/main.css b/polygerrit-ui/app/styles/main.css
new file mode 100644
index 0000000..961fbe9
--- /dev/null
+++ b/polygerrit-ui/app/styles/main.css
@@ -0,0 +1,50 @@
+/*
+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.
+*/
+
+@import "//fonts.googleapis.com/css?family=Open+Sans:400,700";
+@import "//fonts.googleapis.com/css?family=Source+Code+Pro";
+
+*,
+*::after,
+*::before {
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0;
+}
+html,
+body {
+  height: 100%;
+  transition: none; /* Override the default Polymer fade-in. */
+}
+body {
+  font: 11px 'Open Sans', sans-serif;
+}
+
+@media only screen and (min-width: 1240px) {
+  body {
+    font-size: 12px;
+  }
+}
+@media only screen and (min-width: 1340px) {
+  body {
+    font-size: 13px;
+  }
+}
+@media only screen and (min-width: 1450px) {
+  body {
+    font-size: 14px;
+  }
+}
diff --git a/polygerrit-ui/app/test/gr-account-dropdown-test.html b/polygerrit-ui/app/test/gr-account-dropdown-test.html
new file mode 100644
index 0000000..c7be9ba
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-account-dropdown-test.html
@@ -0,0 +1,48 @@
+<!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-account-dropdown</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="../elements/gr-account-dropdown.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-account-dropdown></gr-account-dropdown>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-dropdown tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('tap on trigger opens menu', function() {
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.tap(element.$.trigger);
+      assert.isTrue(element.$.dropdown.opened);
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-change-list-item-test.html b/polygerrit-ui/app/test/gr-change-list-item-test.html
new file mode 100644
index 0000000..bca44a9
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-change-list-item-test.html
@@ -0,0 +1,98 @@
+<!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-change-list-item</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="../scripts/changes.js"></script>
+<script src="../scripts/fake-app.js"></script>
+
+<link rel="import" href="../elements/gr-change-list-item.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-list-item></gr-change-list-item>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-list-item tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('computed fields', function() {
+      assert.equal(element._computeChangeStatusString({mergeable: true}), '');
+      assert.equal(element._computeChangeStatusString({mergeable: false}),
+          'Merge Conflict');
+      assert.equal(element._computeChangeStatusString({status: 'NEW'}), '');
+      assert.equal(element._computeChangeStatusString({status: 'MERGED'}),
+          'Merged');
+      assert.equal(element._computeChangeStatusString({status: 'ABANDONED'}),
+          'Abandoned');
+      assert.equal(element._computeChangeStatusString({status: 'DRAFT'}),
+          'Draft');
+
+      assert.equal(element._computeCodeReviewClass(), 'cell codeReview');
+      assert.equal(element._computeCodeReviewClass({}), 'cell codeReview');
+      assert.equal(element._computeCodeReviewClass({approved: true, value: 1}),
+          'cell codeReview u-green u-monospace');
+      assert.equal(element._computeCodeReviewClass({value: 1}),
+          'cell codeReview u-green u-monospace');
+      assert.equal(element._computeCodeReviewClass({value: -1}),
+          'cell codeReview u-monospace u-red');
+
+      assert.equal(element._computeCodeReviewLabel(), '');
+      assert.equal(element._computeCodeReviewLabel({}), '');
+      assert.equal(element._computeCodeReviewLabel({approved: true, value: 1}),
+          '✓');
+      assert.equal(element._computeCodeReviewLabel({value: 1}), '+1');
+      assert.equal(element._computeCodeReviewLabel({value: -1}), '-1');
+
+      assert.equal(element._computeVerifiedLabel(), '');
+      assert.equal(element._computeVerifiedLabel({}), '');
+      assert.equal(element._computeVerifiedLabel({approved: true}), '✓');
+
+
+      assert.equal(element._computeOwnerLink('andybons+gerrit@gmail.com'),
+          '/q/owner:andybons%2Bgerrit%40gmail.com+status:open');
+
+      assert.equal(element._computeOwnerTitle(
+          {
+            name: 'Andrew Bonventre',
+            email: 'andybons+gerrit@gmail.com'
+          }),
+          'Andrew Bonventre <andybons+gerrit@gmail.com>');
+
+      // TODO(andybons): _computeProjectURL once it's not a constant.
+
+      assert.equal(element._computeProjectBranchURL(
+          'combustible-stuff', 'lemons'),
+          '/q/status:open+project:combustible-stuff+branch:lemons');
+
+      element.change = { _number: 42 };
+      assert.equal(element.changeURL, '/c/42/');
+      element.change = { _number: 43 };
+      assert.equal(element.changeURL, '/c/43/');
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-change-list-test.html b/polygerrit-ui/app/test/gr-change-list-test.html
new file mode 100644
index 0000000..d713368
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-change-list-test.html
@@ -0,0 +1,148 @@
+<!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-change-list</title>
+
+<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/fake-app.js"></script>
+<script src="../scripts/changes.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="../elements/gr-change-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-list></gr-change-list>
+  </template>
+</test-fixture>
+
+<test-fixture id="grouped">
+  <template>
+    <gr-change-list></gr-change-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-list basic tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('keyboard shortcuts', function() {
+      element.changes = [
+        {_number: 0},
+        {_number: 1},
+        {_number: 2},
+      ];
+      flushAsynchronousOperations();
+      var elementItems = Polymer.dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 3);
+      assert.equal(element.selectedIndex, 0);
+
+      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.calledWithExactly('/c/2/'),
+          'Should navigate to /c/2/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+      assert(showStub.lastCall.calledWithExactly('/c/1/'),
+          'Should navigate to /c/1/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+      assert.equal(element.selectedIndex, 0);
+
+      showStub.restore();
+    });
+
+  });
+
+  suite('gr-change-list groups', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('keyboard shortcuts', function() {
+      element.groups = [
+        [
+          {_number: 0},
+          {_number: 1},
+          {_number: 2},
+        ],
+        [
+          {_number: 3},
+          {_number: 4},
+          {_number: 5},
+        ],
+        [
+          {_number: 6},
+          {_number: 7},
+          {_number: 8},
+        ]
+      ];
+      element.groupTitles = ['Group 1', 'Group 2', 'Group 3'];
+      flushAsynchronousOperations();
+      var elementItems = Polymer.dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 9);
+      assert.equal(element.selectedIndex, 0);
+
+      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.calledWithExactly('/c/2/'),
+          'Should navigate to /c/2/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+      assert(showStub.lastCall.calledWithExactly('/c/1/'),
+          'Should navigate to /c/1/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+      assert.equal(element.selectedIndex, 4);
+      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+      assert(showStub.lastCall.calledWithExactly('/c/4/'),
+          'Should navigate to /c/4/');
+      showStub.restore();
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-change-view-test.html b/polygerrit-ui/app/test/gr-change-view-test.html
new file mode 100644
index 0000000..4cf1d4c
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-change-view-test.html
@@ -0,0 +1,61 @@
+<!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-change-view</title>
+
+<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/changes.js"></script>
+<script src="../scripts/fake-app.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="../elements/gr-change-view.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-view></gr-change-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-view tests', function() {
+    var element;
+
+    setup(function() {
+      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 dropdownEl = element.$.replyDropdown;
+      assert.isTrue(dropdownEl.opened);
+      dropdownEl.close();
+      assert.isFalse(dropdownEl.opened);
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-date-formatter-test.html b/polygerrit-ui/app/test/gr-date-formatter-test.html
new file mode 100644
index 0000000..dca3e9d
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-date-formatter-test.html
@@ -0,0 +1,81 @@
+<!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-date-formatter</title>
+
+<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="../elements/gr-date-formatter.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-date-formatter date-str="2015-09-24 23:30:17.033000000"></gr-date-formatter>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-date-formatter tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('date is parsed correctly', function() {
+      assert.notOk((new Date('foo')).valueOf());
+      var d = element._parseDateStr(element.getAttribute('date-str'));
+      assert.isAbove(d.valueOf(), 0);
+    });
+
+    function normalizedDate(dateStr) {
+      var d = new Date(dateStr);
+      d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
+      return d;
+    }
+
+    function testDates(nowStr, dateStr, expected) {
+      var now = normalizedDate(nowStr);
+      var t = normalizedDate(dateStr);
+      assert.equal(element._dateStr(t, now), expected);
+    }
+
+    test('dates strings are correct', function() {
+      // Within 24 hours on same day.
+      testDates('2015-07-29T20:34:00.000Z',
+                '2015-07-29T15:34:00.000Z',
+                '3:34 PM');
+
+      // Within 24 hours on different days.
+      testDates('2015-07-29T03:34:00.000Z',
+                '2015-07-28T20:25:00.000Z',
+                'Jul 28');
+
+      // More than 24 hours. Less than six months.
+      testDates('2015-07-29T20:34:00.000Z',
+                '2015-06-15T03:25:00.000Z',
+                'Jun 15');
+
+      // More than six months.
+      testDates('2015-09-15T20:34:00.000Z',
+                '2015-01-15T03:25:00.000Z',
+                'Jan 15, 2015');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-diff-comment-test.html b/polygerrit-ui/app/test/gr-diff-comment-test.html
new file mode 100644
index 0000000..2e7f9bf
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-diff-comment-test.html
@@ -0,0 +1,253 @@
+<!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-diff-comment</title>
+
+<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="../elements/gr-diff-comment.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-comment></gr-diff-comment>
+  </template>
+</test-fixture>
+
+<test-fixture id="draft">
+  <template>
+    <gr-diff-comment draft="true"></gr-diff-comment>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-comment tests', function() {
+    var element;
+    setup(function() {
+      element = fixture('basic');
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+      }
+    });
+
+    test('proper event fires on reply', function(done) {
+      element.addEventListener('gr-diff-comment-reply', function(e) {
+        done();
+      });
+      MockInteractions.tap(element.$$('.reply'));
+    });
+
+    test('proper event fires on done', function(done) {
+      element.addEventListener('gr-diff-comment-done', function(e) {
+        done();
+      });
+      MockInteractions.tap(element.$$('.done'));
+    });
+
+    test('clicking on date link does not trigger nav', function() {
+      var showStub = sinon.stub(page, 'show');
+      var dateEl = element.$$('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+      var dest = window.location.pathname + '#5';
+      assert(showStub.lastCall.calledWithExactly(dest, null, false),
+          'Should navigate to ' + dest + ' without triggering nav');
+      showStub.restore();
+    });
+  });
+
+  suite('gr-diff-comment draft tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('draft');
+      element.changeNum = 42;
+      element.patchNum = 1;
+      element.comment = {
+        __draft: true,
+        __draftID: 'temp_draft_id',
+        path: '/path/to/file',
+        line: 5,
+      };
+
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'PUT',
+        '/changes/42/revisions/1/drafts',
+        [
+          201,
+          { 'Content-Type': 'application/json' },
+          ')]}\'\n{' +
+            '"id": "baf0414d_40572e03",' +
+            '"path": "/path/to/file",' +
+            '"line": 5,' +
+            '"updated": "2015-12-08 21:52:36.177000000",' +
+            '"message": "created!"' +
+          '}'
+        ]
+      );
+
+      server.respondWith(
+        'PUT',
+        /\/changes\/42\/revisions\/1\/drafts\/.+/,
+        [
+          200,
+          { 'Content-Type': 'application/json' },
+          ')]}\'\n{' +
+            '"id": "baf0414d_40572e03",' +
+            '"path": "/path/to/file",' +
+            '"line": 5,' +
+            '"updated": "2015-12-08 21:52:36.177000000",' +
+            '"message": "saved!"' +
+          '}'
+        ]
+      );
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    function isVisible(el) {
+      assert.ok(el);
+      return getComputedStyle(el).getPropertyValue('display') != 'none';
+    }
+
+    test('button visibility states', function() {
+      element.draft = true;
+      assert.isTrue(isVisible(element.$$('.edit')), 'edit is visible');
+      assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
+      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
+      assert.isFalse(isVisible(element.$$('.reply')), 'reply is not visible');
+      assert.isFalse(isVisible(element.$$('.done')), 'done is not visible');
+
+      element.editing = true;
+      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible')
+      assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
+      assert.isTrue(isVisible(element.$$('.save')), 'save is visible');
+      assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
+      assert.isFalse(isVisible(element.$$('.reply')), 'reply is not visible');
+      assert.isFalse(isVisible(element.$$('.done')), 'done is not visible');
+
+      element.draft = false,
+      element.editing = false;
+      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible')
+      assert.isFalse(isVisible(element.$$('.discard')),
+          'discard is not visible');
+      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
+      assert.isTrue(isVisible(element.$$('.reply')), 'edit is visible');
+      assert.isTrue(isVisible(element.$$('.done')), 'edit is visible');
+
+      element.draft = true;
+    });
+
+    test('draft creation/cancelation', function(done) {
+      assert.isFalse(element.editing);
+      MockInteractions.tap(element.$$('.edit'));
+      assert.isTrue(element.editing);
+
+      element._editDraft = '';
+      // Save should be disabled on an empty message.
+      var disabled = element.$$('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+      element._editDraft == '     ';
+      disabled = element.$$('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+
+      var numDiscardEvents = 0;
+      element.addEventListener('gr-diff-comment-discard', function(e) {
+        numDiscardEvents++;
+        if (numDiscardEvents == 2) {
+          done();
+        }
+      });
+      MockInteractions.tap(element.$$('.cancel'));
+      MockInteractions.tap(element.$$('.discard'));
+    });
+
+    test('draft saving/editing', function(done) {
+      element.draft = true;
+      MockInteractions.tap(element.$$('.edit'));
+      element._editDraft = 'good news, everyone!';
+      MockInteractions.tap(element.$$('.save'));
+      assert.isTrue(element.disabled,
+          'Element should be disabled when creating draft.');
+
+      server.respond();
+
+      element._xhrPromise.then(function(req) {
+        assert.isFalse(element.disabled,
+            'Element should be enabled when done creating draft.');
+        assert.equal(req.status, 201);
+        assert.equal(req.url, '/changes/42/revisions/1/drafts');
+        assert.equal(req.response.message, 'created!');
+        assert.isFalse(element.editing);
+      }).then(function() {
+        MockInteractions.tap(element.$$('.edit'));
+        element._editDraft = 'You’ll be delivering a package to Chapek 9, a ' +
+            'world where humans are killed on sight.';
+        MockInteractions.tap(element.$$('.save'));
+        assert.isTrue(element.disabled,
+            'Element should be disabled when updating draft.');
+        server.respond();
+
+        element._xhrPromise.then(function(req) {
+          assert.isFalse(element.disabled,
+              'Element should be enabled when done updating draft.');
+          assert.equal(req.status, 200);
+          assert.equal(req.url,
+              '/changes/42/revisions/1/drafts/baf0414d_40572e03');
+          assert.equal(req.response.message, 'saved!');
+          assert.isFalse(element.editing);
+          done();
+        });
+      });
+    });
+
+    test('proper event fires on done', function(done) {
+      element.addEventListener('gr-diff-comment-done', function(e) {
+        done();
+      });
+      MockInteractions.tap(element.$$('.done'));
+    });
+
+    test('clicking on date link does not trigger nav', function() {
+      var showStub = sinon.stub(page, 'show');
+      var dateEl = element.$$('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+      var dest = window.location.pathname + '#5';
+      assert(showStub.lastCall.calledWithExactly(dest, null, false),
+          'Should navigate to ' + dest + ' without triggering nav');
+      showStub.restore();
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-diff-comment-thread-test.html b/polygerrit-ui/app/test/gr-diff-comment-thread-test.html
new file mode 100644
index 0000000..7032712
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-diff-comment-thread-test.html
@@ -0,0 +1,116 @@
+<!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-diff-comment-thread</title>
+
+<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="../elements/gr-diff-comment-thread.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-comment-thread></gr-diff-comment-thread>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-comment-thread tests', function() {
+    var element;
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('comments are sorted correctly', 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',
+          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'
+        }
+      ];
+      var results = element._sortedComments(comments);
+      assert.deepEqual(results, [
+        {
+          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'
+        }
+      ]);
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-diff-side-test.html b/polygerrit-ui/app/test/gr-diff-side-test.html
new file mode 100644
index 0000000..b54461b
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-diff-side-test.html
@@ -0,0 +1,144 @@
+<!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-diff-side</title>
+
+<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="../elements/gr-diff-side.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-side></gr-diff-side>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-side tests', function() {
+    var element;
+
+    function isVisibleInWindow(el) {
+      var rect = el.getBoundingClientRect();
+      return rect.top >= 0 && rect.left >= 0 &&
+          rect.bottom <= window.innerHeight && rect.right <= window.innerWidth;
+    }
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('comments', function() {
+      assert.isFalse(element.$$('.container').classList.contains('canComment'));
+      element.canComment = true;
+      assert.isTrue(element.$$('.container').classList.contains('canComment'));
+      // TODO(andybons): Check for comment creation events firing/not firing
+      // when implemented.
+    });
+
+    test('scroll to line', function() {
+      var content = [];
+      for (var i = 0; i < 300; i++) {
+        content.push({
+          type: 'CODE',
+          content: 'All work and no play makes Jack a dull boy',
+          numLines: 1,
+          lineNum: i + 1,
+          highlight: false,
+          intraline: [],
+        });
+      }
+      element._render(content);
+
+      window.scrollTo(0, 0);
+      element.scrollToLine(-12849);
+      assert.equal(window.scrollY, 0);
+      element.scrollToLine('sup');
+      assert.equal(window.scrollY, 0);
+      var lineEl = element.$$('.numbers .lineNum[data-line-num="150"]');
+      assert.ok(lineEl);
+      element.scrollToLine(150);
+      assert.isAbove(window.scrollY, 0);
+      assert.isTrue(isVisibleInWindow(lineEl), 'element should be visible');
+    });
+
+    test('intraline highlights', function() {
+      var content = '        <gr-linked-text content="' +
+          '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>';
+      var html = util.escapeHTML(content);
+      var highlights = [
+        { startIndex: 0, endIndex: 33, },
+        { startIndex: 75 },
+      ];
+      assert.equal(
+          content.slice(highlights[0].startIndex, highlights[0].endIndex),
+          '        <gr-linked-text content="');
+      assert.equal(content.slice(highlights[1].startIndex),
+          '"></gr-linked-text>');
+      var result = element._addIntralineHighlights(content, html, highlights);
+      var expected = element._highlightStartTag +
+          '        &lt;gr-linked-text content=&quot;' +
+          element._highlightEndTag +
+          '[[_computeCurrentRevisionMessage(change)]]' +
+          element._highlightStartTag +
+          '&quot;&gt;&lt;&#x2F;gr-linked-text&gt;' +
+          element._highlightEndTag;
+      assert.equal(result, expected);
+    });
+
+    test('newlines', function() {
+      element.width = 80;
+      var content = [{
+        type: 'CODE',
+        content: 'A'.repeat(50),
+        numLines: 1,
+        lineNum: 1,
+        highlight: false,
+        intraline: [],
+      }];
+      element._render(content);
+
+      var lineEl = element.$$('.numbers .lineNum[data-line-num="1"]');
+      assert.ok(lineEl);
+      var contentEl = element.$$('.content .code[data-line-num="1"]');
+      assert.ok(contentEl);
+      assert.equal(contentEl.innerHTML, 'A'.repeat(50));
+
+      content = [{
+        type: 'CODE',
+        content: 'A'.repeat(100),
+        numLines: 2,
+        lineNum: 1,
+        highlight: false,
+        intraline: [],
+      }];
+      element._render(content);
+
+      lineEl = element.$$('.numbers .lineNum[data-line-num="1"]');
+      assert.ok(lineEl);
+      contentEl = element.$$('.content .code[data-line-num="1"]');
+      assert.ok(contentEl);
+      assert.equal(contentEl.innerHTML,
+          'A'.repeat(80) + element._lineFeedHTML +
+          'A'.repeat(20) + element._lineFeedHTML);
+
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-diff-test.html b/polygerrit-ui/app/test/gr-diff-test.html
new file mode 100644
index 0000000..b3a56d8
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-diff-test.html
@@ -0,0 +1,268 @@
+<!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-diff</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="../scripts/changes.js"></script>
+<script src="../scripts/fake-app.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="../elements/gr-diff.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff auto></gr-diff>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+      element.changeNum = 42;
+      element.path = 'sieve.go';
+
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'GET',
+        /\/changes\/42\/revisions\/(1|2)\/files\/sieve\.go\/diff(.*)/,
+        [
+          200,
+          { 'Content-Type': 'application/json' },
+          ')]}\'\n' +
+          JSON.stringify({
+            change_type: 'MODIFIED',
+            content: [
+              {ab: ['doin some codez and stuffs']},
+            ]
+          }),
+        ]
+      );
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/1/comments',
+        [
+          200,
+          { 'Content-Type': 'application/json' },
+          ')]}\'\n' +
+          JSON.stringify({
+            '/COMMIT_MSG': [],
+            'sieve.go': [
+              {
+                author: {
+                  _account_id: 1000000,
+                  name: 'Andrew Bonventre',
+                  email: 'andybons@gmail.com',
+                },
+                id: '9af53d3f_5f2b8b82',
+                line: 1,
+                message: 'this isn’t quite right',
+                updated: '2015-12-10 02:50:21.627000000',
+              },
+              {
+                author: {
+                  _account_id: 1000000,
+                  name: 'Andrew Bonventre',
+                  email: 'andybons@gmail.com',
+                },
+                id: '9af53d3f_bf1cd76b',
+                line: 1,
+                side: 'PARENT',
+                message: 'how did this work in the first place?',
+                updated: '2015-12-10 00:08:42.255000000',
+              },
+            ],
+          }),
+        ]
+      );
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/2/comments',
+        [
+          200,
+          { 'Content-Type': 'application/json' },
+          ')]}\'\n' +
+          JSON.stringify({
+            '/COMMIT_MSG': [],
+            'sieve.go': [
+              {
+                author: {
+                  _account_id: 1010008,
+                  name: 'Dave Borowitz',
+                  email: 'dborowitz@google.com',
+                },
+                id: '001a2067_f30f3048',
+                line: 17,
+                message: 'What on earth are you thinking, here?',
+                updated: '2015-12-12 02:51:37.973000000',
+              },
+              {
+                author: {
+                  _account_id: 1010008,
+                  name: 'Dave Borowitz',
+                  email: 'dborowitz@google.com',
+                },
+                id: '001a2067_f6b1b1c8',
+                in_reply_to: '9af53d3f_bf1cd76b',
+                line: 1,
+                side: 'PARENT',
+                message: 'Yeah not sure how this worked either?',
+                updated: '2015-12-12 02:51:37.973000000',
+              },
+              {
+                author: {
+                  _account_id: 1000000,
+                  name: 'Andrew Bonventre',
+                  email: 'andybons@gmail.com',
+                },
+                id: 'a0407443_30dfe8fb',
+                in_reply_to: '001a2067_f30f3048',
+                line: 17,
+                message: '¯\\_(ツ)_/¯',
+                updated: '2015-12-12 18:50:21.627000000',
+              },
+            ],
+          }),
+        ]
+      );
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    test('comments with parent', function(done) {
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+
+      server.respond();
+
+      element._diffRequestsPromise.then(function() {
+        assert.equal(element._baseComments.length, 1);
+        assert.equal(element._comments.length, 1);
+        assert.equal(element._baseDrafts.length, 0);
+        assert.equal(element._drafts.length, 0);
+        done();
+      });
+    });
+
+    test('comments between two patches', function(done) {
+      element.patchRange = {
+        basePatchNum: 1,
+        patchNum: 2,
+      };
+
+      server.respond();
+
+      element._diffRequestsPromise.then(function() {
+        assert.equal(element._baseComments.length, 1);
+        assert.equal(element._comments.length, 2);
+        assert.equal(element._baseDrafts.length, 0);
+        assert.equal(element._drafts.length, 0);
+        done();
+      });
+    });
+
+    test('intraline normalization', function() {
+      // The content and highlights are in the format returned by the Gerrit
+      // REST API.
+      var content = [
+        '      <section class="summary">',
+        '        <gr-linked-text content="' +
+            '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+        '      </section>',
+      ];
+      var highlights = [
+        [31, 34], [42, 26]
+      ];
+      var results = element._normalizeIntralineHighlights(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 31,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+          endIndex: 33,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 75,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 0,
+          endIndex: 6,
+        }
+      ]);
+
+      content = [
+        '        this._path = value.path;',
+        '',
+        '        // When navigating away from the page, there is a possibility that the',
+        '        // patch number is no longer a part of the URL (say when navigating to',
+        '        // the top-level change info view) and therefore undefined in `params`.',
+        '        if (!this._patchRange.patchNum) {',
+      ];
+      highlights = [
+        [14, 17],
+        [11, 70],
+        [12, 67],
+        [12, 67],
+        [14, 29],
+      ];
+      results = element._normalizeIntralineHighlights(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 14,
+          endIndex: 31,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 8,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 3,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 4,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 5,
+          startIndex: 12,
+          endIndex: 41,
+        }
+      ]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-diff-view-test.html b/polygerrit-ui/app/test/gr-diff-view-test.html
new file mode 100644
index 0000000..8af2d50
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-diff-view-test.html
@@ -0,0 +1,115 @@
+<!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-diff-view</title>
+
+<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/changes.js"></script>
+<script src="../scripts/fake-app.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="../elements/gr-diff-view.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-view></gr-diff-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-view tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+      element.$.changeDetailXHR.auto = false;
+      element.$.filesXHR.auto = false;
+      element.$.diff.auto = false;
+    });
+
+    test('keyboard shortcuts', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        patchNum: '10',
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+
+      var showStub = sinon.stub(page, 'show');
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42'),
+          'Should navigate to /c/42');
+
+      pressAndReleaseKeyIdentifierOn(element, '\U+005D');  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/wheatley.md'),
+          'Should navigate to /c/42/10/wheatley.md');
+      element._path = 'wheatley.md';
+
+      pressAndReleaseKeyIdentifierOn(element, '\U+005B');  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/glados.txt'),
+          'Should navigate to /c/42/10/glados.txt');
+      element._path = 'glados.txt';
+
+      pressAndReleaseKeyIdentifierOn(element, '\U+005B');  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/chell.go'),
+          'Should navigate to /c/42/10/chell.go');
+      element._path = 'chell.go';
+
+      pressAndReleaseKeyIdentifierOn(element, '\U+005B');  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42'),
+          'Should navigate to /c/42');
+
+      showStub.restore();
+
+      // https://github.com/PolymerElements/iron-test-helpers/issues/33
+      function keyboardEventFor(type, keyIdentifier) {
+        var event = new CustomEvent(type, {
+          bubbles: true,
+          cancelable: true
+        });
+
+        event.keyIdentifier = keyIdentifier;
+
+        return event;
+      }
+
+      function keyEventOn(target, type, keyIdentifier) {
+        target.dispatchEvent(keyboardEventFor(type, keyIdentifier));
+      }
+
+      function keyDownOn(target, keyIdentifier) {
+        keyEventOn(target, 'keydown', keyIdentifier);
+      }
+
+      function keyUpOn(target, keyIdentifier) {
+        keyEventOn(target, 'keyup', keyIdentifier);
+      }
+
+      function pressAndReleaseKeyIdentifierOn(target, keyIdentifier) {
+        keyDownOn(target, keyIdentifier);
+        Polymer.Base.async(function() {
+          keyUpOn(target, keyIdentifier);
+        }, 1);
+      }
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-reply-dropdown-test.html b/polygerrit-ui/app/test/gr-reply-dropdown-test.html
new file mode 100644
index 0000000..4752017
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-reply-dropdown-test.html
@@ -0,0 +1,167 @@
+<!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-reply-dropdown</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="../scripts/changes.js"></script>
+<script src="../scripts/fake-app.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="../elements/gr-reply-dropdown.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-reply-dropdown></gr-reply-dropdown>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-reply-dropdown tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+      element.changeNum = 42;
+      element.patchNum = 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
+        }
+      };
+      element.permittedLabels = {
+        'Code-Review': [
+          '-1',
+          ' 0',
+          '+1'
+        ],
+        Verified: [
+          '-1',
+          ' 0',
+          '+1'
+        ]
+      };
+
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'POST',
+        '/changes/42/revisions/1/review',
+        [
+          200,
+          { 'Content-Type': 'application/json' },
+          ')]}\'\n' +
+          '{' +
+            '"labels": {' +
+              '"Code-Review": -1,' +
+              '"Verified": -1' +
+            '}' +
+          '}'
+        ]
+      );
+
+      // Allow the elements created by dom-repeat to be stamped.
+      flushAsynchronousOperations();
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    test('open close', function() {
+      assert.isFalse(element.opened);
+      MockInteractions.tap(element.$.trigger);
+      assert.isTrue(element.opened);
+      element.close();
+      assert.isFalse(element.opened);
+      element.open();
+      assert.isTrue(element.opened);
+      MockInteractions.tap(element.$$('.cancel'));
+      assert.isFalse(element.opened);
+    });
+
+    test('label picker', function(done) {
+      assert.isFalse(element.opened);
+      MockInteractions.tap(element.$.trigger);
+      assert.isTrue(element.opened);
+
+      // Without this, the test fails to register taps on the label buttons.
+      // TODO(andybons): Look into test flakiness with Polymer team to figure
+      // out why exactly this is happening.
+      element.async(function() {
+        for (var label in element.permittedLabels) {
+          assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
+              label);
+        }
+        element._draft = 'I wholeheartedly disapprove';
+        MockInteractions.tap(element.$$(
+            'iron-selector[data-label="Code-Review"] > button[data-value="-1"]'));
+        MockInteractions.tap(element.$$(
+            'iron-selector[data-label="Verified"] > button[data-value="-1"]'));
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
+        element.async(function() {
+          MockInteractions.tap(element.$$('.send'));
+          assert.isTrue(element.disabled);
+
+          server.respond();
+
+          element._xhrPromise.then(function(req) {
+            assert.isFalse(element.disabled,
+                'Element should be enabled when done sending reply.');
+            assert.isFalse(element.opened);
+            assert.equal(req.status, 200);
+            assert.equal(req.url, '/changes/42/revisions/1/review');
+            var reqObj = JSON.parse(req.xhr.requestBody);
+            assert.deepEqual(reqObj, {
+              drafts: 'PUBLISH_ALL_REVISIONS',
+              labels: {
+                'Code-Review': -1,
+                'Verified': -1
+              },
+              message: 'I wholeheartedly disapprove'
+            });
+            assert.equal(req.response.labels['Code-Review'], -1);
+            assert.equal(req.response.labels['Verified'], -1);
+            assert.isFalse(element.opened);
+            done();
+          });
+        }, 1);
+      }, 1);
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-search-bar-test.html b/polygerrit-ui/app/test/gr-search-bar-test.html
new file mode 100644
index 0000000..2063d33
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-search-bar-test.html
@@ -0,0 +1,63 @@
+<!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-search-bar</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="../elements/gr-search-bar.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-search-bar></gr-search-bar>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-search-bar tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('value is propagated to _inputVal', function() {
+      element.value = 'foo';
+      assert.equal(element._inputVal, 'foo');
+    });
+
+    test('tap on search button triggers nav', function(done) {
+      sinon.stub(element, '_preventDefaultAndNavigateToInputVal', function() {
+        element._preventDefaultAndNavigateToInputVal.restore();
+        done();
+      });
+      MockInteractions.tap(element.$.searchButton);
+    });
+
+    test('enter in search input triggers nav', function(done) {
+      sinon.stub(element, '_preventDefaultAndNavigateToInputVal', function() {
+        element._preventDefaultAndNavigateToInputVal.restore();
+        done();
+      });
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput, 13);
+    });
+
+  });
+</script>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
new file mode 100644
index 0000000..42cd24f
--- /dev/null
+++ b/polygerrit-ui/app/test/index.html
@@ -0,0 +1,44 @@
+<!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>Elements Test Runner</title>
+<meta charset="utf-8">
+<script src="../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script>
+  var testFiles = [];
+
+  [ 'gr-account-dropdown-test.html',
+    'gr-change-list-item-test.html',
+    'gr-change-list-test.html',
+    'gr-change-view-test.html',
+    'gr-date-formatter-test.html',
+    'gr-diff-comment-test.html',
+    'gr-diff-comment-thread-test.html',
+    'gr-diff-side-test.html',
+    'gr-diff-test.html',
+    'gr-diff-view-test.html',
+    'gr-reply-dropdown-test.html',
+    'gr-search-bar-test.html',
+  ].forEach(function(file) {
+    testFiles.push(file);
+    testFiles.push(file + '?dom=shadow');
+  });
+
+  WCT.loadSuites(testFiles);
+</script>
diff --git a/polygerrit-ui/run-server.sh b/polygerrit-ui/run-server.sh
new file mode 100755
index 0000000..70ee3cb
--- /dev/null
+++ b/polygerrit-ui/run-server.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+# 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.
+
+set -eu
+
+while [[ ! -f .buckconfig && "$PWD" != / ]]; do
+  cd ..
+done
+if [[ ! -f .buckconfig ]]; then
+  echo "$(basename "$0"): must be run from a gerrit checkout" 1>&2
+  exit 1
+fi
+
+cd polygerrit-ui
+rm -rf bower_components
+buck build //polygerrit-ui:polygerrit_components
+unzip -q ../buck-out/gen/polygerrit-ui/polygerrit_components/polygerrit_components.bower_components.zip
+exec go run server.go
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
new file mode 100644
index 0000000..5fe6e36
--- /dev/null
+++ b/polygerrit-ui/server.go
@@ -0,0 +1,155 @@
+// 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 main
+
+import (
+	"bufio"
+	"compress/gzip"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"net/http"
+	"net/url"
+	"regexp"
+	"strings"
+)
+
+var (
+	restHost = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
+	port     = flag.String("port", ":8081", "Port to serve HTTP requests on")
+	prod     = flag.Bool("prod", false, "Serve production assets")
+	loggedIn = flag.Bool("logged_in", false, "Return user info as if the user is logged in")
+)
+
+func main() {
+	flag.Parse()
+
+	if *prod {
+		http.Handle("/", http.FileServer(http.Dir("dist")))
+	} else {
+		http.Handle("/bower_components/",
+			http.StripPrefix("/bower_components/", http.FileServer(http.Dir("bower_components"))))
+		http.Handle("/", http.FileServer(http.Dir("app")))
+	}
+
+	http.HandleFunc("/changes/", handleRESTProxy)
+	http.HandleFunc("/accounts/", handleRESTProxy)
+	http.HandleFunc("/config/", handleRESTProxy)
+	http.HandleFunc("/accounts/self/detail", handleAccountDetail)
+	log.Println("Serving on port", *port)
+	log.Fatal(http.ListenAndServe(*port, &server{}))
+}
+
+func handleRESTProxy(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+	req := &http.Request{
+		Method: "GET",
+		URL: &url.URL{
+			Scheme:   "https",
+			Host:     *restHost,
+			Opaque:   r.URL.EscapedPath(),
+			RawQuery: r.URL.RawQuery,
+		},
+	}
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer res.Body.Close()
+	w.WriteHeader(res.StatusCode)
+	if _, err := io.Copy(w, res.Body); err != nil {
+		log.Println("Error copying response to ResponseWriter:", err)
+		return
+	}
+}
+
+func handleAccountDetail(w http.ResponseWriter, r *http.Request) {
+	if !*loggedIn {
+		http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+		return
+	}
+	fmt.Fprint(w, accountInfo)
+}
+
+const accountInfo = `)]}'
+{
+  "registered_on": "2015-08-31 21:24:17.614000000",
+  "_account_id": 1021482,
+  "name": "Andrew Bonventre",
+  "email": "andybons@chromium.org",
+  "avatars": [
+    {
+      "url": "https://lh4.googleusercontent.com/-1EovlES413I/AAAAAAAAAAI/AAAAAAAAAAA/GQ5-31ULE1Q/s26-p/photo.jpg",
+      "height": 26
+    }
+  ]
+}`
+
+type gzipResponseWriter struct {
+	io.WriteCloser
+	http.ResponseWriter
+}
+
+func newGzipResponseWriter(w http.ResponseWriter) *gzipResponseWriter {
+	gz := gzip.NewWriter(w)
+	return &gzipResponseWriter{WriteCloser: gz, ResponseWriter: w}
+}
+
+func (w gzipResponseWriter) Write(b []byte) (int, error) {
+	return w.WriteCloser.Write(b)
+}
+
+func (w gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+	h, ok := w.ResponseWriter.(http.Hijacker)
+	if !ok {
+		return nil, nil, errors.New("gzipResponseWriter: ResponseWriter does not satisfy http.Hijacker interface")
+	}
+	return h.Hijack()
+}
+
+type server struct{}
+
+// Any path prefixes that should resolve to index.html.
+var (
+	fePaths    = []string{"/q/", "/c/", "/dashboard/"}
+	issueNumRE = regexp.MustCompile(`^\/\d+\/?$`)
+)
+
+func (_ *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	log.Printf("%s %s %s %s\n", r.Proto, r.Method, r.RemoteAddr, r.URL)
+	for _, prefix := range fePaths {
+		if strings.HasPrefix(r.URL.Path, prefix) {
+			r.URL.Path = "/"
+			log.Println("Redirecting to /")
+			break
+		} else if match := issueNumRE.Find([]byte(r.URL.Path)); match != nil {
+			r.URL.Path = "/"
+			log.Println("Redirecting to /")
+			break
+		}
+	}
+	if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
+		http.DefaultServeMux.ServeHTTP(w, r)
+		return
+	}
+	w.Header().Set("Content-Encoding", "gzip")
+	gzw := newGzipResponseWriter(w)
+	defer gzw.Close()
+	http.DefaultServeMux.ServeHTTP(gzw, r)
+}
diff --git a/polygerrit-ui/wct.conf.js b/polygerrit-ui/wct.conf.js
new file mode 100644
index 0000000..b6a6251
--- /dev/null
+++ b/polygerrit-ui/wct.conf.js
@@ -0,0 +1,17 @@
+var path = require('path');
+
+var ret = {
+  suites: ['app/test'],
+  webserver: {
+    pathMappings: []
+  }
+};
+
+var mapping = {};
+var rootPath = (__dirname).split(path.sep).slice(-1)[0];
+
+mapping['/components/' + rootPath  + '/app/bower_components'] = 'bower_components';
+
+ret.webserver.pathMappings.push(mapping);
+
+module.exports = ret;
diff --git a/tools/build.defs b/tools/build.defs
index 893abba..3ea506c 100644
--- a/tools/build.defs
+++ b/tools/build.defs
@@ -61,15 +61,20 @@
   )
 
 def gerrit_war(name, ui = 'ui_optdbg', context = [], docs = False, visibility = []):
+  ui_deps = []
+  if ui:
+    if ui == 'polygerrit' or ui == 'ui_optdbg' or ui == 'ui_optdbg_r':
+      ui_deps.append('//polygerrit-ui/app:polygerrit_ui')
+    if ui != 'polygerrit':
+      ui_deps.append('//gerrit-gwtui:%s' % ui)
   war(
     name = name,
     libs = LIBS + ['//gerrit-war:version'],
     pgmlibs = PGMLIBS,
-    context = [
+    context = ui_deps + context + [
       '//gerrit-main:main_bin',
       '//gerrit-war:webapp_assets',
-    ] + (['//gerrit-gwtui:' + ui] if ui else []) +
-    context,
+    ],
     docs = docs,
     visibility = visibility,
   )
diff --git a/tools/default.defs b/tools/default.defs
index 90096b2..191dfe5 100644
--- a/tools/default.defs
+++ b/tools/default.defs
@@ -27,7 +27,6 @@
 # Set defaults on java rules:
 #  - Add AutoValue annotation processing support.
 #  - Treat source files as UTF-8.
-#  - std_out_log_level = info (the default is too spammy)
 
 _buck_java_library = java_library
 def java_library(*args, **kwargs):
@@ -37,9 +36,9 @@
 _buck_java_test = java_test
 def java_test(*args, **kwargs):
   _munge_args(kwargs)
-  _do_not_spam_std_out(kwargs)
   _buck_java_test(*args, **kwargs)
 
+
 # Munge kwargs to set Gerrit-specific defaults.
 def _munge_args(kwargs):
   _set_auto_value(kwargs)
@@ -57,11 +56,6 @@
 
   extra_args.extend(['-encoding', 'UTF-8'])
 
-def _do_not_spam_std_out(kwargs):
-  level = 'std_out_log_level'
-  if level not in kwargs:
-    kwargs[level] = 'INFO'
-
 def _set_auto_value(kwargs):
   apk = 'annotation_processors'
   if apk not in kwargs:
@@ -79,6 +73,18 @@
     apds.extend(AUTO_VALUE_PROCESSOR_DEPS)
 
 
+# Add 'license' argument to genrule.
+_buck_genrule = genrule
+def genrule(*args, **kwargs):
+  license = kwargs.pop('license', None)
+  if license:
+    license = '//lib:LICENSE-%s' % license
+    # genrule has no deps attribute, but locations listed in the command show
+    # up as deps of the target with buck audit.
+    kwargs['cmd'] = 'true $(location %s); %s' % (license, kwargs['cmd'])
+  _buck_genrule(*args, **kwargs)
+
+
 def genantlr(
     name,
     srcs,
diff --git a/tools/download_file.py b/tools/download_file.py
index 97d982f..bd67b50 100755
--- a/tools/download_file.py
+++ b/tools/download_file.py
@@ -21,7 +21,7 @@
 import shutil
 from subprocess import check_call, CalledProcessError
 from sys import stderr
-from util import resolve_url
+from util import hash_file, resolve_url
 from zipfile import ZipFile, BadZipfile, LargeZipFile
 
 GERRIT_HOME = path.expanduser('~/.gerritcodereview')
@@ -33,17 +33,6 @@
 LOCAL_PROPERTIES = 'local.properties'
 
 
-def hashfile(p):
-  d = sha1()
-  with open(p, 'rb') as f:
-    while True:
-      b = f.read(8192)
-      if not b:
-        break
-      d.update(b)
-  return d.hexdigest()
-
-
 def safe_mkdirs(d):
   if path.isdir(d):
     return
@@ -148,7 +137,7 @@
     exit(1)
 
 if args.v:
-  have = hashfile(cache_ent)
+  have = hash_file(sha1(), cache_ent).hexdigest()
   if args.v != have:
     print((
       '%s:\n' +
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 754c1c8..9fbede3 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -76,7 +76,7 @@
     if path.exists(path.join(root, 'src', 'test', 'java')):
       testpath = """
   <classpathentry kind="src" path="src/test/java"\
- out="buck-out/eclipse/test"/>"""
+ out="eclipse-out/test"/>"""
     else:
       testpath = ""
     print("""\
@@ -85,7 +85,7 @@
   <classpathentry 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="buck-out/eclipse/classes"/>
+  <classpathentry kind="output" path="eclipse-out/classes"/>
 </classpath>""" % {"testpath": testpath}, file=fd)
 
 def gen_classpath():
@@ -141,12 +141,12 @@
     out = None
 
     if s.startswith('lib/'):
-      out = 'buck-out/eclipse/lib'
+      out = 'eclipse-out/lib'
     elif s.startswith('plugins/'):
       if args.plugins:
         plugins.add(s)
         continue
-      out = 'buck-out/eclipse/' + s
+      out = 'eclipse-out/' + s
 
     p = path.join(s, 'java')
     if path.exists(p):
@@ -158,7 +158,7 @@
       if out:
         o = out + '/' + env
       elif env == 'test':
-        o = 'buck-out/eclipse/test'
+        o = 'eclipse-out/test'
 
       for srctype in ['java', 'resources']:
         p = path.join(s, 'src', env, srctype)
@@ -179,10 +179,10 @@
   for s in sorted(gwt_src):
     p = path.join(ROOT, s, 'src', 'main', 'java')
     if path.exists(p):
-      classpathentry('lib', p, out='buck-out/eclipse/gwtsrc')
+      classpathentry('lib', p, out='eclipse-out/gwtsrc')
 
   classpathentry('con', JRE)
-  classpathentry('output', 'buck-out/eclipse/classes')
+  classpathentry('output', 'eclipse-out/classes')
 
   p = path.join(ROOT, '.classpath')
   with open(p, 'w') as fd:
diff --git a/tools/js/BUCK b/tools/js/BUCK
new file mode 100644
index 0000000..ba4f19c
--- /dev/null
+++ b/tools/js/BUCK
@@ -0,0 +1,20 @@
+python_binary(
+  name = 'bower2buck',
+  main = 'bower2buck.py',
+  deps = ['//tools:util'],
+  visibility = ['PUBLIC'],
+)
+
+python_binary(
+  name = 'download_bower',
+  main = 'download_bower.py',
+  deps = ['//tools:util'],
+  visibility = ['PUBLIC'],
+)
+
+python_binary(
+  name = 'run_npm_binary',
+  main = 'run_npm_binary.py',
+  deps = ['//tools:util'],
+  visibility = ['PUBLIC'],
+)
diff --git a/tools/js/bower2buck.py b/tools/js/bower2buck.py
new file mode 100755
index 0000000..31c6dfe
--- /dev/null
+++ b/tools/js/bower2buck.py
@@ -0,0 +1,214 @@
+#!/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.
+
+from __future__ import print_function
+
+import atexit
+import collections
+import json
+import hashlib
+import optparse
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+from tools import util
+
+
+# This script is run with `buck run`, but needs to shell out to buck; this is
+# only possible if we avoid buckd.
+BUCK_ENV = dict(os.environ)
+BUCK_ENV['NO_BUCKD'] = '1'
+
+HEADER = """\
+include_defs('//lib/js.defs')
+
+# AUTOGENERATED BY BOWER2BUCK
+#
+# This file should be merged with an existing BUCK file containing these rules.
+#
+# This comment SHOULD NOT be copied to the existing BUCK file, and you should
+# leave alone any non-bower_component contents of the file.
+#
+# Generally, the following attributes SHOULD be copied from this file to the
+# existing BUCK file:
+#  - package: the normalized package name
+#  - version: the exact version number
+#  - deps: direct dependencies of the package
+#  - sha1: a hash of the package contents
+#
+# The following fields SHOULD NOT be copied to the existing BUCK file:
+#  - semver: manually-specified semantic version, not included in autogenerated
+#    output.
+#
+# The following fields require SPECIAL HANDLING:
+#  - license: all licenses in this file are specified as TODO. You must replace
+#    this text with one of the existing licenses defined in lib/BUCK, or
+#    define a new one if necessary. Leave existing licenses alone.
+
+"""
+
+
+def usage():
+  print(('Usage: %s -o <outfile> [//path/to:bower_components_rule...]'
+         % sys.argv[0]),
+        file=sys.stderr)
+  return 1
+
+
+class Rule(object):
+  def __init__(self, bower_json_path):
+    with open(bower_json_path) as f:
+      bower_json = json.load(f)
+    self.name = bower_json['name']
+    self.version = bower_json['version']
+    self.deps = bower_json.get('dependencies', {})
+    self.license = bower_json['license']
+    self.sha1 = util.hash_bower_component(
+        hashlib.sha1(), os.path.dirname(bower_json_path)).hexdigest()
+
+  def to_rule(self, packages):
+    if self.name not in packages:
+      raise ValueError('No package name found for %s' % self.name)
+
+    lines = [
+        'bower_component(',
+        "  name = '%s'," % self.name,
+        "  package = '%s'," % packages[self.name],
+        "  version = '%s'," % self.version,
+        ]
+    if self.deps:
+      if len(self.deps) == 1:
+        lines.append("  deps = [':%s']," % next(self.deps.iterkeys()))
+      else:
+        lines.append('  deps = [')
+        lines.extend("    ':%s'," % d for d in sorted(self.deps.iterkeys()))
+        lines.append('  ],')
+    lines.extend([
+        "  license = 'TODO: %s'," % self.license,
+        "  sha1 = '%s'," % self.sha1,
+        ')'])
+    return '\n'.join(lines)
+
+
+def build_bower_json(targets, buck_out):
+  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'] = {}
+
+  deps = subprocess.check_output(
+      ['buck', 'query', '-v', '0',
+       "filter('__download_bower', deps(%s))" % '+'.join(targets)],
+      env=BUCK_ENV)
+  deps = deps.replace('__download_bower', '__bower_version').split()
+  subprocess.check_call(['buck', 'build'] + deps, env=BUCK_ENV)
+
+  for dep in deps:
+    dep = dep.replace(':', '/').lstrip('/')
+    depout = os.path.basename(dep)
+    version_json = os.path.join(buck_out, 'gen', dep, depout)
+    with open(version_json) as f:
+      bower_json['dependencies'].update(json.load(f))
+
+  tmpdir = tempfile.mkdtemp()
+  atexit.register(lambda: shutil.rmtree(tmpdir))
+  ret = os.path.join(tmpdir, 'bower.json')
+  with open(ret, 'w') as f:
+    json.dump(bower_json, f, indent=2)
+  return ret
+
+
+def get_package_name(name, package_version):
+  v = package_version.lower()
+  if '#' in v:
+    return v[:v.find('#')]
+  return name
+
+
+def get_packages(path):
+  with open(path) as f:
+    bower_json = json.load(f)
+  return dict((n, get_package_name(n, v))
+              for n, v in bower_json.get('dependencies', {}).iteritems())
+
+
+def collect_rules(packages):
+  # TODO(dborowitz): Use run_npm_binary instead of system bower.
+  rules = {}
+  subprocess.check_call(['bower', 'install'])
+  for dirpath, dirnames, filenames in os.walk('.', topdown=True):
+    if '.bower.json' not in filenames:
+      continue
+    del dirnames[:]
+    rule = Rule(os.path.join(dirpath, '.bower.json'))
+    rules[rule.name] = rule
+
+    # Oddly, the package name referred to in the deps section of dependents,
+    # e.g. 'PolymerElements/iron-ajax', is not found anywhere in this
+    # bower.json, which only contains 'iron-ajax'. Build up a map of short name
+    # to package name so we can resolve them later.
+    # TODO(dborowitz): We can do better:
+    #  - Infer 'user/package' from GitHub URLs (i.e. a simple subset of Bower's package
+    #    resolution logic).
+    #  - Resolve aliases using https://bower.herokuapp.com/packages/shortname
+    #    (not currently biting us but it might in the future.)
+    for n, v in rule.deps.iteritems():
+      p = get_package_name(n, v)
+      old = packages.get(n)
+      if old is not None and old != p:
+        raise ValueError('multiple packages named %s: %s != %s' % (n, p, old))
+      packages[n] = p
+
+  return rules
+
+
+def find_buck_out():
+  dir = os.getcwd()
+  while not os.path.isfile(os.path.join(dir, '.buckconfig')):
+    dir = os.path.dirname(dir)
+  return os.path.join(dir, 'buck-out')
+
+
+def main(args):
+  opts = optparse.OptionParser()
+  opts.add_option('-o', help='output file location')
+  opts, args = opts.parse_args()
+
+  if not opts.o or not all(a.startswith('//') for a in args):
+    return usage()
+  outfile = os.path.abspath(opts.o)
+  buck_out = find_buck_out()
+
+  targets = args if args else ['//polygerrit-ui/...']
+  bower_json_path = build_bower_json(targets, buck_out)
+  os.chdir(os.path.dirname(bower_json_path))
+  packages = get_packages(bower_json_path)
+  rules = collect_rules(packages)
+
+  with open(outfile, 'w') as f:
+    f.write(HEADER)
+    for _, r in sorted(rules.iteritems()):
+      f.write('\n\n%s' % r.to_rule(packages))
+
+  print('Wrote bower_components rules to:\n  %s' % outfile)
+
+
+if __name__ == '__main__':
+  main(sys.argv[1:])
diff --git a/tools/js/download_bower.py b/tools/js/download_bower.py
new file mode 100644
index 0000000..bcc417c
--- /dev/null
+++ b/tools/js/download_bower.py
@@ -0,0 +1,121 @@
+#!/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.
+
+from __future__ import print_function
+
+import hashlib
+import json
+import optparse
+import os
+import shutil
+import subprocess
+import sys
+
+from tools import util
+
+
+CACHE_DIR = os.path.expanduser(os.path.join(
+    '~', '.gerritcodereview', 'buck-cache', 'downloaded-artifacts'))
+
+
+def bower_cmd(bower, *args):
+  cmd = bower.split(' ')
+  cmd.extend(args)
+  return cmd
+
+
+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)
+  out, err = p.communicate()
+  if p.returncode:
+    sys.stderr.write(err)
+    raise OSError('Command failed: %s' % cmd)
+
+  try:
+    info = json.loads(out)
+  except ValueError:
+    raise ValueError('invalid JSON from %s:\n%s' % (cmd, out))
+  info_name = info.get('name')
+  if info_name != name:
+    raise ValueError('expected package name %s, got: %s' % (name, info_name))
+  return info
+
+
+def ignore_deps(info):
+  # Tell bower to ignore dependencies so we just download this component. This
+  # is just an optimization, since we only pick out the component we need, but
+  # it's important when downloading sizable dependency trees.
+  #
+  # As of 1.6.5 I don't think ignoredDependencies can be specified on the
+  # command line with --config, so we have to create .bowerrc.
+  deps = info.get('dependencies')
+  if deps:
+    with open(os.path.join('.bowerrc'), 'w') as f:
+      json.dump({'ignoredDependencies': deps.keys()}, f)
+
+
+def cache_entry(name, package, version, sha1):
+  if not sha1:
+    sha1 = hashlib.sha1('%s#%s' % (package, version)).hexdigest()
+  return os.path.join(CACHE_DIR, '%s-%s.zip-%s' % (name, version, sha1))
+
+
+def main(args):
+  opts = optparse.OptionParser()
+  opts.add_option('-n', help='short name of component')
+  opts.add_option('-b', help='bower command')
+  opts.add_option('-p', help='full package name of component')
+  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()
+
+  cwd = os.getcwd()
+  outzip = os.path.join(cwd, opts.o)
+  cached = cache_entry(opts.n, opts.p, opts.v, opts.s)
+
+  if not os.path.exists(cached):
+    info = bower_info(opts.b, opts.n, opts.p, opts.v)
+    ignore_deps(info)
+    subprocess.check_call(
+        bower_cmd(opts.b, '--quiet', 'install', '%s#%s' % (opts.p, opts.v)))
+    bc = os.path.join(cwd, 'bower_components')
+    subprocess.check_call(
+        ['zip', '-q', '--exclude', '.bower.json', '-r', cached, opts.n],
+        cwd=bc)
+
+    if opts.s:
+      path = os.path.join(bc, opts.n)
+      sha1 = util.hash_bower_component(hashlib.sha1(), path).hexdigest()
+      if opts.s != sha1:
+        print((
+          '%s#%s:\n'
+          'expected %s\n'
+          'received %s\n') % (opts.p, opts.v, opts.s, sha1), file=sys.stderr)
+        try:
+          os.remove(cached)
+        except OSError as err:
+          if path.exists(cached):
+            print('error removing %s: %s' % (cached, err), file=sys.stderr)
+        return 1
+
+  shutil.copyfile(cached, outzip)
+  return 0
+
+
+if __name__ == '__main__':
+  sys.exit(main(sys.argv[1:]))
diff --git a/tools/js/npm_pack.py b/tools/js/npm_pack.py
new file mode 100755
index 0000000..9eb6e34
--- /dev/null
+++ b/tools/js/npm_pack.py
@@ -0,0 +1,74 @@
+#!/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.
+
+from __future__ import print_function
+
+import atexit
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+
+
+def is_bundled(tar):
+  # No entries for directories, so scan for a matching prefix.
+  for entry in tar.getmembers():
+    if entry.name.startswith('package/node_modules/'):
+      return True
+  return False
+
+
+def bundle_dependencies():
+  with open('package.json') as f:
+    package = json.load(f)
+  package['bundledDependencies'] = package['dependencies'].keys()
+  with open('package.json', 'w') as f:
+    json.dump(package, f)
+
+
+def main(args):
+  if len(args) != 2:
+    print('Usage: %s <package> <version>' % sys.argv[0], file=sys.stderr)
+    return 1
+
+  name, version = args
+  filename = '%s-%s.tgz' % (name, version)
+  url = 'http://registry.npmjs.org/%s/-/%s' % (name, filename)
+
+  tmpdir = tempfile.mkdtemp();
+  tgz = os.path.join(tmpdir, filename)
+  atexit.register(lambda: shutil.rmtree(tmpdir))
+
+  subprocess.check_call(['curl', '--proxy-anyauth', '-ksfo', tgz, url])
+  with tarfile.open(tgz, 'r:gz') as tar:
+    if is_bundled(tar):
+      print('%s already has bundled node_modules' % filename)
+      return 1
+    tar.extractall(path=tmpdir)
+
+  oldpwd = os.getcwd()
+  os.chdir(os.path.join(tmpdir, 'package'))
+  bundle_dependencies()
+  subprocess.check_call(['npm', 'install'])
+  subprocess.check_call(['npm', 'pack'])
+  shutil.copy(filename, os.path.join(oldpwd, filename))
+  return 0
+
+
+if __name__ == '__main__':
+  sys.exit(main(sys.argv[1:]))
diff --git a/tools/js/run_npm_binary.py b/tools/js/run_npm_binary.py
new file mode 100644
index 0000000..d76eff5
--- /dev/null
+++ b/tools/js/run_npm_binary.py
@@ -0,0 +1,91 @@
+#!/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.
+
+from __future__ import print_function
+
+import atexit
+from distutils import spawn
+import hashlib
+import os
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+
+from tools import util
+
+
+def extract(path, outdir, bin):
+  if os.path.exists(os.path.join(outdir, bin)):
+    return # Another process finished extracting, ignore.
+
+  # Use a temp directory adjacent to outdir so shutil.move can use the same
+  # device atomically.
+  tmpdir = tempfile.mkdtemp(dir=os.path.dirname(outdir))
+  def cleanup():
+    try:
+      shutil.rmtree(tmpdir)
+    except OSError:
+      pass # Too late now
+  atexit.register(cleanup)
+
+  def extract_one(mem):
+    dest = os.path.join(outdir, mem.name)
+    tar.extract(mem, path=tmpdir)
+    try:
+      os.makedirs(os.path.dirname(dest))
+    except OSError:
+      pass # Either exists, or will fail on the next line.
+    shutil.move(os.path.join(tmpdir, mem.name), dest)
+
+  with tarfile.open(path, 'r:gz') as tar:
+    for mem in tar.getmembers():
+      if mem.name != bin:
+        extract_one(mem)
+    # Extract bin last so other processes only short circuit when extraction is
+    # 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()
+  outdir = '%s-%s' % (path[:-len(suffix)], sha1)
+  rel_bin = os.path.join('package', 'bin', name)
+  bin = os.path.join(outdir, rel_bin)
+  if not os.path.isfile(bin):
+    extract(path, outdir, rel_bin)
+
+  nodejs = spawn.find_executable('nodejs')
+  if nodejs:
+    # Debian installs Node.js as 'nodejs', due to a conflict with another
+    # package.
+    subprocess.check_call([nodejs, bin] + args[1:])
+  else:
+    subprocess.check_call([bin] + args[1:])
+
+
+if __name__ == '__main__':
+  sys.exit(main(sys.argv[1:]))
diff --git a/tools/util.py b/tools/util.py
index ec895dd..96f6047 100644
--- a/tools/util.py
+++ b/tools/util.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import os
 from os import path
 
 REPO_ROOTS = {
@@ -49,3 +50,53 @@
   root = root.rstrip('/')
   rest = rest.lstrip('/')
   return '/'.join([root, rest])
+
+
+def hash_file(hash_obj, path):
+  """Hash the contents of a file.
+
+  Args:
+    hash_obj: an open hash object, e.g. hashlib.sha1().
+    path: path to the file to hash.
+
+  Returns:
+    The passed-in hash_obj.
+  """
+  with open(path, 'rb') as f:
+    while True:
+      b = f.read(8192)
+      if not b:
+        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