Merge branch 'stable-2.15'

* stable-2.15:
  Include trailing slash in link to settings
  Remove two-way databinding from patch range select
  Doc: Set default character set for MySQL

Change-Id: I1879695346144e547bd7dd72719e182f985ea41d
diff --git a/Documentation/dev-plugin-pg-styling.txt b/Documentation/dev-plugin-pg-styling.txt
deleted file mode 100644
index 618d984..0000000
--- a/Documentation/dev-plugin-pg-styling.txt
+++ /dev/null
@@ -1,61 +0,0 @@
-= Gerrit Code Review - PolyGerrit Plugin Styling
-
-CAUTION: Work in progress. Hard hat area. +
-This document will be populated with details along with implementation. +
-link:https://groups.google.com/d/topic/repo-discuss/vb8WJ4m0hK0/discussion[Join the discussion.]
-
-== Plugin styles
-
-Plugins may provide link:https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[Polymer style modules] for UI CSS-based customization.
-
-PolyGerrit UI implements number of styling endpoints, which apply CSS mixins link:https://tabatkins.github.io/specs/css-apply-rule/[using @apply] to its direct contents.
-
-NOTE: Only items (ie CSS properties and mixin targets) documented here are guaranteed to work in the long term, since they are covered by integration tests. +
-When there is a need to add new property or endpoint, please link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue[file a bug] stating your usecase to track and maintain for future releases.
-
-Plugin should be html-based and imported following PolyGerrit's link:dev-plugins-pg.html#loading[dev guide].
-
-Plugin should provide Style Module, for example:
-
-``` html
-  <dom-module id="some-style">
-    <style>
-      :root {
-        --css-mixin-name: {
-          property: value;
-        }
-      }
-    </style>
-  </dom-module>
-```
-
-Plugin should register style module with a styling endpoint using `Plugin.prototype.registerStyleModule(endpointName, styleModuleName)`, for example:
-
-``` js
-  Gerrit.install(function(plugin) {
-    plugin.registerStyleModule('some-endpoint', 'some-style');
-  });
-```
-
-== Available styling endpoints
-=== change-metadata
-Following custom css mixins are recognized:
-
-* `--change-metadata-assignee`
-+
-is applied to `gr-change-metadata section.assignee`
-* `--change-metadata-label-status`
-+
-is applied to `gr-change-metadata section.labelStatus`
-* `--change-metadata-strategy`
-+
-is applied to `gr-change-metadata section.strategy`
-* `--change-metadata-topic`
-+
-is applied to `gr-change-metadata section.topic`
-
-Following CSS properties have link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html[long-term support via integration test]:
-
-* `display`
-+
-can be set to `none` to hide a section.
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 039d545..ddf7e7f 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -73,27 +73,11 @@
 
 == Create the Actual Release
 
-To create a Gerrit release the following steps have to be done:
-
-. link:#build-gerrit[Build the Gerrit Release]
-. link:#publish-gerrit[Publish the Gerrit Release]
-.. link:#publish-to-maven-central[Publish the Gerrit artifacts to Maven Central]
-.. link:#publish-to-google-storage[Publish the Gerrit WAR to Google Storage]
-.. link:#push-stable[Push the Stable Branch]
-.. link:#push-tag[Push the Release Tag]
-.. link:#upload-documentation[Upload the Documentation]
-.. link:#finalize-release-notes[Finalize Release Notes]
-.. link:#update-issues[Update the Issues]
-.. link:#announce[Announce on Mailing List]
-. link:#increase-version[Increase Gerrit Version for Current Development]
-. link:#merge-stable[Merge `stable` into `master`]
-
-
 [[update-versions]]
 === Update Versions and Create Release Tag
 
 Before doing the release build, the `GERRIT_VERSION` in the `version.bzl`
-file must be updated, e.g. change it from `2.5-SNAPSHOT` to `2.5`.
+file must be updated, e.g. change it from `$version-SNAPSHOT` to `$version`.
 
 In addition the version must be updated in a number of pom.xml files.
 
@@ -107,13 +91,14 @@
 Commit the changes and create a signed release tag on the new commit:
 
 ----
-  git tag -s -m "v2.5" v2.5
+  version=2.15
+  git tag -s -m "v$version" "v$version"
 ----
 
 Tag the plugins:
 
 ----
-  git submodule foreach git tag -s -m "v2.5" v2.5
+  git submodule foreach git tag -s -m "v$version" "v$version"
 ----
 
 [[build-gerrit]]
@@ -126,8 +111,12 @@
   ./tools/maven/api.sh install
 ----
 
-* Sanity check WAR
-* Test the new Gerrit version
+* Verify the WAR version:
++
+----
+  java -jar ~/dl/gerrit-$version.war --version
+----
+* Try upgrading a test site and launching the daemon
 
 * Verify plugin versions
 +
@@ -257,11 +246,11 @@
 [[push-stable]]
 ==== Push the Stable Branch
 
-* Create the stable branch `stable-2.5` in the `gerrit` project via the
+* Create the stable branch `stable-$version` in the `gerrit` project via the
 link:https://gerrit-review.googlesource.com/#/admin/projects/gerrit,branches[
 Gerrit Web UI] or by push.
 
-* Push the commits done on `stable-2.5` to `refs/for/stable-2.5` and
+* Push the commits done on `stable-$version` to `refs/for/stable-$version` and
 get them merged
 
 
@@ -271,13 +260,13 @@
 Push the new Release Tag:
 
 ----
-  git push gerrit-review tag v2.5
+  git push gerrit-review tag v$version
 ----
 
 Push the new Release Tag on the plugins:
 
 ----
-  git submodule foreach git push gerrit-review tag v2.5
+  git submodule foreach git push gerrit-review tag v$version
 ----
 
 
@@ -314,11 +303,11 @@
 Update the issues by hand. There is no script for this.
 
 Our current process is an issue should be updated to say `Status =
-Submitted, FixedIn-2.5` once the change is submitted, but before the
+Submitted, FixedIn-$version` once the change is submitted, but before the
 release.
 
 After the release is actually made, you can search in Google Code for
-`Status=Submitted FixedIn=2.5` and then batch update these changes
+`Status=Submitted FixedIn=$version` and then batch update these changes
 to say `Status=Released`. Make sure the pulldown says `All Issues`
 because `Status=Submitted` is considered a closed issue.
 
diff --git a/Documentation/dev-plugins-pg.txt b/Documentation/pg-plugin-dev.txt
similarity index 66%
rename from Documentation/dev-plugins-pg.txt
rename to Documentation/pg-plugin-dev.txt
index e1bf39e..92c52f6 100644
--- a/Documentation/dev-plugins-pg.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -1,10 +1,5 @@
 = Gerrit Code Review - PolyGerrit Plugin Development
 
-CAUTION: Work in progress. Hard hat area. +
-This document will be populated with details along with implementation. +
-link:https://groups.google.com/d/topic/repo-discuss/vb8WJ4m0hK0/discussion[Join
-the discussion.]
-
 [[loading]]
 == Plugin loading and initialization
 
@@ -33,8 +28,8 @@
 </dom-module>
 ```
 
-[[low-level-api]]
-== Low-level DOM API
+[[low-level-api-concepts]]
+== Low-level DOM API concepts
 
 Basically, the DOM is the API surface. Low-level API provides methods for
 decorating, replacing, and styling DOM elements exposed through a set of
@@ -126,3 +121,119 @@
   </style>
 </dom-module>
 ```
+
+[[high-level-api-concepts]]
+== High-level DOM API concepts
+
+High leve API is based on low-level DOM API and is essentially a standartized
+way for doing common tasks. It's less flexible, but will be a bit more stable.
+
+Common way to access high-leve API is through `plugin` instance passed into
+setup callback parameter of `Gerrit.install()`, also sometimes referred as
+`self`.
+
+[[low-level-api]]
+== Low-level DOM API
+
+Low-level DOM API methods are the base of all UI customization.
+
+=== attributeHelper
+`plugin.attributeHelper(element)`
+
+Note: TODO
+
+=== eventHelper
+`plugin.eventHelper(element)`
+
+Note: TODO
+
+=== hook
+`plugin.hook(endpointName, opt_options)`
+
+Note: TODO
+
+=== registerCustomComponent
+`plugin.registerCustomComponent(endpointName, opt_moduleName, opt_options)`
+
+Note: TODO
+
+=== registerStyleModule
+`plugin.registerStyleModule(endpointName, moduleName)`
+
+Note: TODO
+
+[[high-level-api]]
+== High-level API
+
+Plugin instance provides access to number of more specific APIs and methods
+to be used by plugin authors.
+
+=== changeReply
+`plugin.changeReply()`
+
+Note: TODO
+
+=== changeView
+`plugin.changeView()`
+
+Note: TODO
+
+=== delete
+`plugin.delete(url, opt_callback)`
+
+Note: TODO
+
+=== get
+`plugin.get(url, opt_callback)`
+
+Note: TODO
+
+=== getPluginName
+`plugin.getPluginName()`
+
+Note: TODO
+
+=== getServerInfo
+`plugin.getServerInfo()`
+
+Note: TODO
+
+=== on
+`plugin.on(eventName, callback)`
+
+Note: TODO
+
+=== popup
+`plugin.popup(moduleName)`
+
+Note: TODO
+
+=== post
+`plugin.post(url, payload, opt_callback)`
+
+Note: TODO
+
+[plugin-project]
+=== project
+`plugin.project()`
+
+.Params:
+- none
+
+.Returns:
+- Instance of link:pg-plugin-project-api.html[GrProjectApi].
+
+=== put
+`plugin.put(url, payload, opt_callback)`
+
+Note: TODO
+
+=== theme
+`plugin.theme()`
+
+Note: TODO
+
+=== url
+`plugin.url(opt_path)`
+
+Note: TODO
diff --git a/Documentation/pg-plugin-project-api.txt b/Documentation/pg-plugin-project-api.txt
new file mode 100644
index 0000000..897430c
--- /dev/null
+++ b/Documentation/pg-plugin-project-api.txt
@@ -0,0 +1,36 @@
+= Gerrit Code Review - Project admin customization API
+
+This API is provided by link:pg-plugin-dev.html#plugin-project[plugin.project()]
+and provides customization to admin page.
+
+== createCommand
+`projectApi.createCommand(title, checkVisibleCallback)`
+
+Create a project command in the admin panel.
+
+.Params
+- *title* String title.
+- *checkVisibleCallback* function to configure command visibility.
+
+.Returns
+- GrProjectApi for chainging.
+
+`checkVisibleCallback(projectName, projectConfig)`
+
+.Params
+- *projectName* String project name.
+- *projectConfig* Object REST API response for project config.
+
+.Returns
+- `false` to hide the command for the specific project.
+
+== onTap
+`projectApi.onTap(tapCalback)`
+
+Add a command tap callback.
+
+.Params
+- *tapCallback* function that's excuted on command tap.
+
+.Returns
+- Nothing
diff --git a/Documentation/pg-plugin-styling.txt b/Documentation/pg-plugin-styling.txt
new file mode 100644
index 0000000..58b6d7a
--- /dev/null
+++ b/Documentation/pg-plugin-styling.txt
@@ -0,0 +1,69 @@
+= Gerrit Code Review - PolyGerrit Plugin Styling
+
+== Plugin styles
+
+Plugins may provide
+link:https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[Polymer
+style modules] for UI CSS-based customization.
+
+PolyGerrit UI implements number of styling endpoints, which apply CSS mixins
+link:https://tabatkins.github.io/specs/css-apply-rule/[using @apply] to its
+direct contents.
+
+NOTE: Only items (ie CSS properties and mixin targets) documented here are
+guaranteed to work in the long term, since they are covered by integration
+tests. + When there is a need to add new property or endpoint, please
+link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue[file
+a bug] stating your usecase to track and maintain for future releases.
+
+Plugin should be html-based and imported following PolyGerrit's
+link:pg-plugin-dev.html#loading[dev guide].
+
+Plugin should provide Style Module, for example:
+
+``` html
+  <dom-module id="some-style">
+    <style>
+      :root {
+        --css-mixin-name: {
+          property: value;
+        }
+      }
+    </style>
+  </dom-module>
+```
+
+Plugin should register style module with a styling endpoint using
+`Plugin.prototype.registerStyleModule(endpointName, styleModuleName)`, for
+example:
+
+``` js
+  Gerrit.install(function(plugin) {
+    plugin.registerStyleModule('some-endpoint', 'some-style');
+  });
+```
+
+== Available styling endpoints
+=== change-metadata
+Following custom css mixins are recognized:
+
+* `--change-metadata-assignee`
++
+is applied to `gr-change-metadata section.assignee`
+* `--change-metadata-label-status`
++
+is applied to `gr-change-metadata section.labelStatus`
+* `--change-metadata-strategy`
++
+is applied to `gr-change-metadata section.strategy`
+* `--change-metadata-topic`
++
+is applied to `gr-change-metadata section.topic`
+
+Following CSS properties have
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html[long-term
+support via integration test]:
+
+* `display`
++
+can be set to `none` to hide a section.
diff --git a/WORKSPACE b/WORKSPACE
index ac4d04b..6458571 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -192,18 +192,6 @@
     sha1 = "de80fe047052445869b96f6def6baca7182c95af",
 )
 
-maven_jar(
-    name = "joda_time",
-    artifact = "joda-time:joda-time:2.9.9",
-    sha1 = "f7b520c458572890807d143670c9b24f4de90897",
-)
-
-maven_jar(
-    name = "joda_convert",
-    artifact = "org.joda:joda-convert:1.8.1",
-    sha1 = "675642ac208e0b741bc9118dcbcae44c271b992a",
-)
-
 load("//lib:guava.bzl", "GUAVA_VERSION", "GUAVA_BIN_SHA1")
 
 maven_jar(
@@ -922,8 +910,8 @@
 # When upgrading Elasticsearch, make sure it's compatible with Lucene
 maven_jar(
     name = "elasticsearch",
-    artifact = "org.elasticsearch:elasticsearch:2.4.5",
-    sha1 = "daafe48ae06592029a2fedca1fe2ac0f5eec3185",
+    artifact = "org.elasticsearch:elasticsearch:2.4.6",
+    sha1 = "d2954e1173a608a9711f132d1768a676a8b1fb81",
 )
 
 # Java REST client for Elasticsearch.
@@ -942,6 +930,18 @@
 )
 
 maven_jar(
+    name = "joda_time",
+    artifact = "joda-time:joda-time:2.9.9",
+    sha1 = "f7b520c458572890807d143670c9b24f4de90897",
+)
+
+maven_jar(
+    name = "joda_convert",
+    artifact = "org.joda:joda-convert:1.8.1",
+    sha1 = "675642ac208e0b741bc9118dcbcae44c271b992a",
+)
+
+maven_jar(
     name = "compress_lzf",
     artifact = "com.ning:compress-lzf:1.0.2",
     sha1 = "62896e6fca184c79cc01a14d143f3ae2b4f4b4ae",
diff --git a/gerrit-acceptance-framework/pom.xml b/gerrit-acceptance-framework/pom.xml
index 2b1dcb0..a0f2e67 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.15-rc0</version>
+  <version>2.16-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index 0fa09af..0de6a30 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.api.plugins.Plugins.ListRequest;
 import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RawInput;
@@ -107,12 +108,21 @@
     api = gApi.plugins().name("plugin-a");
     assertThat(api.get().disabled).isNull();
     assertPlugins(list().get(), PLUGINS);
+
+    // Non-admin cannot disable
+    setApiUser(user);
+    try {
+      gApi.plugins().name("plugin-a").disable();
+      fail("Expected AuthException");
+    } catch (AuthException expected) {
+      // Expected
+    }
   }
 
   @Test
   public void installNotAllowed() throws Exception {
     exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("remote installation is disabled");
+    exception.expectMessage("remote plugin administration is disabled");
     gApi.plugins().install("test.js", new InstallPluginInput());
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java
index b140a6e..6f4495e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -16,12 +16,15 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -56,20 +59,40 @@
 
   @Test
   public void getDashboard() throws Exception {
-    assertThat(dashboards()).isEmpty();
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     DashboardInfo result = project().dashboard(info.id).get();
-    assertThat(result.id).isEqualTo(info.id);
-    assertThat(result.path).isEqualTo(info.path);
-    assertThat(result.ref).isEqualTo(info.ref);
-    assertThat(result.project).isEqualTo(project.get());
-    assertThat(result.definingProject).isEqualTo(project.get());
-    assertThat(dashboards()).hasSize(1);
+    assertDashboardInfo(result, info);
+  }
+
+  @Test
+  public void getDashboardWithNoDescription() throws Exception {
+    DashboardInfo info = newDashboardInfo(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    info.description = null;
+    DashboardInfo created = createDashboard(info);
+    assertThat(created.description).isNull();
+    DashboardInfo result = project().dashboard(created.id).get();
+    assertThat(result.description).isNull();
+  }
+
+  @Test
+  public void getDashboardNonDefault() throws Exception {
+    DashboardInfo info = createTestDashboard("my", "test");
+    DashboardInfo result = project().dashboard(info.id).get();
+    assertDashboardInfo(result, info);
+  }
+
+  @Test
+  public void listDashboards() throws Exception {
+    assertThat(dashboards()).isEmpty();
+    DashboardInfo info1 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
+    DashboardInfo info2 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
+    assertThat(dashboards().stream().map(d -> d.id).collect(toList()))
+        .containsExactly(info1.id, info2.id);
   }
 
   @Test
   public void setDefaultDashboard() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().dashboard(info.id).setDefault();
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
@@ -78,7 +101,7 @@
 
   @Test
   public void setDefaultDashboardByProject() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().defaultDashboard(info.id);
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
@@ -93,8 +116,8 @@
 
   @Test
   public void replaceDefaultDashboard() throws Exception {
-    DashboardInfo d1 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
-    DashboardInfo d2 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
+    DashboardInfo d1 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
+    DashboardInfo d2 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
     assertThat(d1.isDefault).isNull();
     assertThat(d2.isDefault).isNull();
     project().dashboard(d1.id).setDefault();
@@ -109,12 +132,28 @@
 
   @Test
   public void cannotGetDashboardWithInheritedForNonDefault() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     exception.expect(BadRequestException.class);
     exception.expectMessage("inherited flag can only be used with default");
     project().dashboard(info.id).get(true);
   }
 
+  private void assertDashboardInfo(DashboardInfo actual, DashboardInfo expected) throws Exception {
+    assertThat(actual.id).isEqualTo(expected.id);
+    assertThat(actual.path).isEqualTo(expected.path);
+    assertThat(actual.ref).isEqualTo(expected.ref);
+    assertThat(actual.project).isEqualTo(project.get());
+    assertThat(actual.definingProject).isEqualTo(project.get());
+    assertThat(actual.description).isEqualTo(expected.description);
+    assertThat(actual.title).isEqualTo(expected.title);
+    assertThat(actual.foreach).isEqualTo(expected.foreach);
+    if (expected.sections == null) {
+      assertThat(actual.sections).isNull();
+    } else {
+      assertThat(actual.sections.size()).isEqualTo(expected.sections.size());
+    }
+  }
+
   private List<DashboardInfo> dashboards() throws Exception {
     return project().dashboards().get();
   }
@@ -123,8 +162,27 @@
     return gApi.projects().name(project.get());
   }
 
-  private DashboardInfo createDashboard(String ref, String path) throws Exception {
+  private DashboardInfo newDashboardInfo(String ref, String path) {
     DashboardInfo info = DashboardsCollection.newDashboardInfo(ref, path);
+    info.title = "Reviewer";
+    info.description = "Own review requests";
+    info.foreach = "owner:self";
+    DashboardSectionInfo section = new DashboardSectionInfo();
+    section.name = "Open";
+    section.query = "is:open";
+    info.sections = ImmutableList.of(section);
+    return info;
+  }
+
+  private DashboardInfo createTestDashboard() throws Exception {
+    return createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+  }
+
+  private DashboardInfo createTestDashboard(String ref, String path) throws Exception {
+    return createDashboard(newDashboardInfo(ref, path));
+  }
+
+  private DashboardInfo createDashboard(DashboardInfo info) throws Exception {
     String canonicalRef = DashboardsCollection.normalizeDashboardRef(info.ref);
     try {
       project().branch(canonicalRef).create(new BranchInput());
@@ -137,13 +195,23 @@
     try (Repository r = repoManager.openRepository(project)) {
       TestRepository<Repository>.CommitBuilder cb =
           new TestRepository<>(r).branch(canonicalRef).commit();
-      String content =
-          "[dashboard]\n"
-              + "Description = Test\n"
-              + "foreach = owner:self\n"
-              + "[section \"Mine\"]\n"
-              + "query = is:open";
-      cb.add(info.path, content);
+      StringBuilder content = new StringBuilder("[dashboard]\n");
+      if (info.title != null) {
+        content.append("title = ").append(info.title).append("\n");
+      }
+      if (info.description != null) {
+        content.append("description = ").append(info.description).append("\n");
+      }
+      if (info.foreach != null) {
+        content.append("foreach = ").append(info.foreach).append("\n");
+      }
+      if (info.sections != null) {
+        for (DashboardSectionInfo section : info.sections) {
+          content.append("[section \"").append(section.name).append("\"]\n");
+          content.append("query = ").append(section.query).append("\n");
+        }
+      }
+      cb.add(info.path, content.toString());
       RevCommit c = cb.create();
       project().commit(c.name());
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
index 990bad6..6bfecfa 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
@@ -4,7 +4,4 @@
     srcs = ["ChangeEditIT.java"],
     group = "edit",
     labels = ["edit"],
-    deps = [
-        "//lib/joda:joda-time",
-    ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
index 43ec5bc..897b99f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
@@ -16,7 +16,6 @@
     srcs = ["AbstractPushForReview.java"],
     deps = [
         "//gerrit-acceptance-tests:lib",
-        "//lib/joda:joda-time",
     ],
 )
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
index b7ed2e8..49f00f9 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
@@ -15,7 +15,6 @@
     labels = ["rest"],
     deps = [
         ":submit_util",
-        "//lib/joda:joda-time",
     ],
 )
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
index 6f4bdab..32f1ce5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.server.mail.receive.MailMessage;
+import java.time.Instant;
 import java.util.HashMap;
-import org.joda.time.DateTime;
 import org.junit.Ignore;
 
 @Ignore
@@ -36,7 +36,7 @@
     b.from(user.emailAddress);
     b.addTo(user.emailAddress); // Not evaluated
     b.subject("");
-    b.dateReceived(new DateTime());
+    b.dateReceived(Instant.now());
     return b;
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
index 71a6135..c3a4e20 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
@@ -2,7 +2,6 @@
 
 DEPS = [
     "//lib/greenmail",
-    "//lib/joda:joda-time",
     "//lib/mail",
 ]
 
diff --git a/gerrit-common/BUILD b/gerrit-common/BUILD
index 4389080..d9d4392 100644
--- a/gerrit-common/BUILD
+++ b/gerrit-common/BUILD
@@ -28,7 +28,6 @@
         "//lib:gwtorm_client",
         "//lib:servlet-api-3_1",
         "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/joda:joda-time",
         "//lib/log:api",
     ],
     gwt_xml = SRC + "Common.gwt.xml",
@@ -53,7 +52,6 @@
         "//lib:gwtorm",
         "//lib:servlet-api-3_1",
         "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/joda:joda-time",
         "//lib/log:api",
     ],
 )
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
index a8e40c6..b1697dc 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
@@ -15,14 +15,21 @@
 package com.google.gerrit.common;
 
 import com.google.common.annotations.GwtIncompatible;
+import com.google.common.annotations.VisibleForTesting;
 import java.sql.Timestamp;
-import org.joda.time.DateTimeUtils;
+import java.util.function.LongSupplier;
 
 /** Static utility methods for dealing with dates and times. */
-@GwtIncompatible("Unemulated org.joda.time.DateTimeUtils")
+@GwtIncompatible("Unemulated Java 8 functionalities")
 public class TimeUtil {
+  private static final LongSupplier SYSTEM_CURRENT_MILLIS_SUPPLIER = System::currentTimeMillis;
+
+  private static volatile LongSupplier currentMillisSupplier = SYSTEM_CURRENT_MILLIS_SUPPLIER;
+
   public static long nowMs() {
-    return DateTimeUtils.currentTimeMillis();
+    // We should rather use Instant.now(Clock).toEpochMilli() instead but this would require some
+    // changes in our testing code as we wouldn't have clock steps anymore.
+    return currentMillisSupplier.getAsLong();
   }
 
   public static Timestamp nowTs() {
@@ -33,5 +40,15 @@
     return new Timestamp((t.getTime() / 1000) * 1000);
   }
 
+  @VisibleForTesting
+  public static void setCurrentMillisSupplier(LongSupplier customCurrentMillisSupplier) {
+    currentMillisSupplier = customCurrentMillisSupplier;
+  }
+
+  @VisibleForTesting
+  public static void resetCurrentMillisSupplier() {
+    currentMillisSupplier = SYSTEM_CURRENT_MILLIS_SUPPLIER;
+  }
+
   private TimeUtil() {}
 }
diff --git a/gerrit-elasticsearch/BUILD b/gerrit-elasticsearch/BUILD
index fb86aaf..d278bcf 100644
--- a/gerrit-elasticsearch/BUILD
+++ b/gerrit-elasticsearch/BUILD
@@ -17,10 +17,10 @@
         "//lib/elasticsearch",
         "//lib/elasticsearch:jest",
         "//lib/elasticsearch:jest-common",
+        "//lib/elasticsearch:joda-time",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/joda:joda-time",
         "//lib/log:api",
         "//lib/lucene:lucene-analyzers-common",
         "//lib/lucene:lucene-core",
diff --git a/gerrit-extension-api/pom.xml b/gerrit-extension-api/pom.xml
index 7179f46..a8ae2e6 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.15-rc0</version>
+  <version>2.16-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/gerrit-httpd/BUILD b/gerrit-httpd/BUILD
index dbca10c..cc2160f 100644
--- a/gerrit-httpd/BUILD
+++ b/gerrit-httpd/BUILD
@@ -77,6 +77,5 @@
         "//lib/guice:guice-servlet",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
-        "//lib/joda:joda-time",
     ],
 )
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 51c60af..c3b522a 100644
--- a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -21,7 +21,7 @@
  * @param staticResourcePath
  * @param? versionInfo
  */
-{template .Index autoescape="strict" kind="html"}
+{template .Index kind="html"}
   <!DOCTYPE html>{\n}
   <html lang="en">{\n}
   <meta charset="utf-8">{\n}
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
index 18256c6..6dd15bc 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -36,9 +36,11 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.attribute.FileTime;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.zip.GZIPInputStream;
-import org.joda.time.format.ISODateTimeFormat;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -91,7 +93,12 @@
   @Before
   public void setUp() {
     fs = Jimfs.newFileSystem(Configuration.unix());
-    ts = new AtomicLong(ISODateTimeFormat.dateTime().parseMillis("2010-01-30T12:00:00.000-08:00"));
+    ts =
+        new AtomicLong(
+            LocalDateTime.of(2010, Month.JANUARY, 30, 12, 0, 0)
+                .atOffset(ZoneOffset.ofHours(-8))
+                .toInstant()
+                .toEpochMilli());
   }
 
   @Test
diff --git a/gerrit-pgm/BUILD b/gerrit-pgm/BUILD
index 1fd3165..60663d7 100644
--- a/gerrit-pgm/BUILD
+++ b/gerrit-pgm/BUILD
@@ -22,7 +22,6 @@
     "//lib/guice:guice-assistedinject",
     "//lib/guice:guice-servlet",
     "//lib/jgit/org.eclipse.jgit:jgit",
-    "//lib/joda:joda-time",
     "//lib/log:api",
     "//lib/log:log4j",
 ]
diff --git a/gerrit-plugin-api/BUILD b/gerrit-plugin-api/BUILD
index fe9ce19..5ed4b8c 100644
--- a/gerrit-plugin-api/BUILD
+++ b/gerrit-plugin-api/BUILD
@@ -29,7 +29,6 @@
     "//lib/httpcomponents:httpcore",
     "//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet",
     "//lib/jgit/org.eclipse.jgit:jgit",
-    "//lib/joda:joda-time",
     "//lib/log:api",
     "//lib/log:log4j",
     "//lib/mina:sshd",
diff --git a/gerrit-plugin-api/pom.xml b/gerrit-plugin-api/pom.xml
index c99220f..84df44a 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.15-rc0</version>
+  <version>2.16-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/gerrit-plugin-gwtui/pom.xml b/gerrit-plugin-gwtui/pom.xml
index 122f54a..cc9aafc 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.15-rc0</version>
+  <version>2.16-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-server/BUILD b/gerrit-server/BUILD
index e124e89..e285ee5 100644
--- a/gerrit-server/BUILD
+++ b/gerrit-server/BUILD
@@ -92,7 +92,6 @@
         "//lib/guice:guice-servlet",
         "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
         "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/joda:joda-time",
         "//lib/jsoup",
         "//lib/log:api",
         "//lib/log:jsonevent-layout",
@@ -181,7 +180,6 @@
     "//lib/guice:guice-servlet",
     "//lib/jgit/org.eclipse.jgit:jgit",
     "//lib/jgit/org.eclipse.jgit.junit:junit",
-    "//lib/joda:joda-time",
     "//lib/log:api",
     "//lib/log:impl_log4j",
     "//lib/log:log4j",
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
index 21da0b8..dc180cc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -240,6 +241,8 @@
 
         try {
           return copy(res.files(), res.patchSetId(), resource, userId);
+        } catch (PatchListObjectTooLargeException e) {
+          log.warn("Cannot copy patch review flags: " + e.getMessage());
         } catch (IOException | PatchListNotAvailableException e) {
           log.warn("Cannot copy patch review flags", e);
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java
index 4a87474..c5d60a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java
@@ -16,16 +16,16 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import java.text.MessageFormat;
+import java.time.DayOfWeek;
+import java.time.Duration;
+import java.time.LocalTime;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoUnit;
 import java.util.Locale;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
-import org.joda.time.DateTime;
-import org.joda.time.LocalDateTime;
-import org.joda.time.LocalTime;
-import org.joda.time.MutableDateTime;
-import org.joda.time.format.DateTimeFormat;
-import org.joda.time.format.DateTimeFormatter;
-import org.joda.time.format.ISODateTimeFormat;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -49,16 +49,16 @@
   }
 
   public ScheduleConfig(Config rc, String section, String subsection) {
-    this(rc, section, subsection, DateTime.now());
+    this(rc, section, subsection, ZonedDateTime.now());
   }
 
   public ScheduleConfig(
       Config rc, String section, String subsection, String keyInterval, String keyStartTime) {
-    this(rc, section, subsection, keyInterval, keyStartTime, DateTime.now());
+    this(rc, section, subsection, keyInterval, keyStartTime, ZonedDateTime.now());
   }
 
   @VisibleForTesting
-  ScheduleConfig(Config rc, String section, String subsection, DateTime now) {
+  ScheduleConfig(Config rc, String section, String subsection, ZonedDateTime now) {
     this(rc, section, subsection, KEY_INTERVAL, KEY_STARTTIME, now);
   }
 
@@ -69,7 +69,7 @@
       String subsection,
       String keyInterval,
       String keyStartTime,
-      DateTime now) {
+      ZonedDateTime now) {
     this.rc = rc;
     this.section = section;
     this.subsection = subsection;
@@ -122,31 +122,24 @@
       String section,
       String subsection,
       String keyStartTime,
-      DateTime now,
+      ZonedDateTime now,
       long interval) {
     long delay = MISSING_CONFIG;
     String start = rc.getString(section, subsection, keyStartTime);
     try {
       if (start != null) {
-        DateTimeFormatter formatter;
-        MutableDateTime startTime = now.toMutableDateTime();
+        DateTimeFormatter formatter =
+            DateTimeFormatter.ofPattern("[E ]HH:mm").withLocale(Locale.US);
+        LocalTime firstStartTime = LocalTime.parse(start, formatter);
+        ZonedDateTime startTime = now.with(firstStartTime);
         try {
-          formatter = ISODateTimeFormat.hourMinute();
-          LocalTime firstStartTime = formatter.parseLocalTime(start);
-          startTime.hourOfDay().set(firstStartTime.getHourOfDay());
-          startTime.minuteOfHour().set(firstStartTime.getMinuteOfHour());
-        } catch (IllegalArgumentException e1) {
-          formatter = DateTimeFormat.forPattern("E HH:mm").withLocale(Locale.US);
-          LocalDateTime firstStartDateTime = formatter.parseLocalDateTime(start);
-          startTime.dayOfWeek().set(firstStartDateTime.getDayOfWeek());
-          startTime.hourOfDay().set(firstStartDateTime.getHourOfDay());
-          startTime.minuteOfHour().set(firstStartDateTime.getMinuteOfHour());
+          DayOfWeek dayOfWeek = formatter.parse(start, DayOfWeek::from);
+          startTime = startTime.with(dayOfWeek);
+        } catch (DateTimeParseException ignored) {
+          // Day of week is an optional parameter.
         }
-        startTime.secondOfMinute().set(0);
-        startTime.millisOfSecond().set(0);
-        long s = startTime.getMillis();
-        long n = now.getMillis();
-        delay = (s - n) % interval;
+        startTime = startTime.truncatedTo(ChronoUnit.MINUTES);
+        delay = Duration.between(now, startTime).toMillis() % interval;
         if (delay <= 0) {
           delay += interval;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
index afd78dc..2614eaf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
@@ -56,6 +56,7 @@
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -425,6 +426,8 @@
         p.insertions = patch.getInsertions();
         patchSetAttribute.files.add(p);
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Cannot get patch list: " + e.getMessage());
     } catch (PatchListNotAvailableException e) {
       log.warn("Cannot get patch list", e);
     }
@@ -498,6 +501,8 @@
       p.kind = changeKindCache.getChangeKind(db, change, patchSet);
     } catch (IOException | OrmException e) {
       log.error("Cannot load patch set data for " + patchSet.getId(), e);
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn(String.format("Cannot get size information for %s: %s", pId, e.getMessage()));
     } catch (PatchListNotAvailableException e) {
       log.error(String.format("Cannot get size information for %s.", pId), e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index f9fc60a..1415f3b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -70,6 +71,8 @@
           util.logEventListenerError(this, l, e);
         }
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
     } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index feaa54a..3bba164 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -64,6 +65,8 @@
           util.logEventListenerError(this, l, e);
         }
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
     } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 03a6f1f..0437623 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -63,6 +64,8 @@
           util.logEventListenerError(this, l, e);
         }
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
     } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index e76a032..1676c2c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -74,6 +75,8 @@
           util.logEventListenerError(this, l, e);
         }
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
     } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index e4f8572..d785f38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -67,6 +68,8 @@
           util.logEventListenerError(this, l, e);
         }
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
     } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 033efe2..9914563 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -78,6 +79,8 @@
           util.logEventListenerError(this, listener, e);
         }
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
     } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 8a781d0..475e4b5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -64,6 +65,8 @@
           util.logEventListenerError(this, l, e);
         }
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
     } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index 71a603c..5f20293 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -78,6 +79,8 @@
           util.logEventListenerError(this, l, e);
         }
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
     } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java
index 68b3c23..0d20464 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.mail.Address;
-import org.joda.time.DateTime;
+import java.time.Instant;
 
 /**
  * A simplified representation of an RFC 2045-2047 mime email message used for representing received
@@ -40,7 +40,7 @@
 
   public abstract ImmutableList<Address> cc();
   // Metadata
-  public abstract DateTime dateReceived();
+  public abstract Instant dateReceived();
 
   public abstract ImmutableList<String> additionalHeaders();
   // Content
@@ -84,7 +84,7 @@
       return this;
     }
 
-    public abstract Builder dateReceived(DateTime val);
+    public abstract Builder dateReceived(Instant instant);
 
     public abstract ImmutableList.Builder<String> additionalHeadersBuilder();
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java
index d2f91ed..57fe21f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java
@@ -33,7 +33,6 @@
 import org.apache.james.mime4j.dom.TextBody;
 import org.apache.james.mime4j.dom.address.Mailbox;
 import org.apache.james.mime4j.message.DefaultMessageBuilder;
-import org.joda.time.DateTime;
 
 /** Parses raw email content received through POP3 or IMAP into an internal {@link MailMessage}. */
 public class RawMailParser {
@@ -66,7 +65,9 @@
     if (mimeMessage.getSubject() != null) {
       messageBuilder.subject(mimeMessage.getSubject());
     }
-    messageBuilder.dateReceived(new DateTime(mimeMessage.getDate()));
+    if (mimeMessage.getDate() != null) {
+      messageBuilder.dateReceived(mimeMessage.getDate().toInstant());
+    }
 
     // Add From, To and Cc
     if (mimeMessage.getFrom() != null && mimeMessage.getFrom().size() > 0) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 53e7d22..a7826cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -539,6 +540,9 @@
         // Currently these always have a null oldId in the PatchList.
         return "[Octopus merge; cannot be formatted as a diff.]\n";
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Cannot format patch " + e.getMessage());
+      return "";
     } catch (PatchListNotAvailableException e) {
       log.error("Cannot format patch", e);
       return "";
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
index 5b7d3b7..e8f2522 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.server.OrmException;
@@ -232,6 +233,8 @@
     if (repo != null) {
       try {
         patchList = getPatchList();
+      } catch (PatchListObjectTooLargeException e) {
+        log.warn("Failed to get patch list: " + e.getMessage());
       } catch (PatchListNotAvailableException e) {
         log.error("Failed to get patch list", e);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index 7777400..8900a15 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -103,7 +103,7 @@
     try {
       PatchList pl = fileCache.get(key, fileLoaderFactory.create(key, project));
       if (pl instanceof LargeObjectTombstone) {
-        throw new PatchListNotAvailableException(
+        throw new PatchListObjectTooLargeException(
             "Error computing " + key + ". Previous attempt failed with LargeObjectException");
       }
       if (key.getAlgorithm() == PatchListKey.Algorithm.OPTIMIZED_DIFF) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java
new file mode 100644
index 0000000..54e0e6c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+/**
+ * Exception thrown when the PatchList could not be computed because previous attempts failed with
+ * {@code LargeObjectException}. This is not thrown on the first computation.
+ */
+public class PatchListObjectTooLargeException extends PatchListNotAvailableException {
+  private static final long serialVersionUID = 1L;
+
+  public PatchListObjectTooLargeException(String message) {
+    super(message);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
index a2da580..ac37af4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
@@ -15,32 +15,42 @@
 package com.google.gerrit.server.plugins;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.common.PluginInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugins.DisablePlugin.Input;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 public class DisablePlugin implements RestModifyView<PluginResource, Input> {
   public static class Input {}
 
   private final PluginLoader loader;
+  private final Provider<IdentifiedUser> user;
+  private final PermissionBackend permissionBackend;
 
   @Inject
-  DisablePlugin(PluginLoader loader) {
+  DisablePlugin(
+      PluginLoader loader, Provider<IdentifiedUser> user, PermissionBackend permissionBackend) {
     this.loader = loader;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input) throws MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote plugin administration is disabled");
+  public PluginInfo apply(PluginResource resource, Input input) throws RestApiException {
+    try {
+      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (PermissionBackendException e) {
+      throw new RestApiException("Could not check permission", e);
     }
+    loader.checkRemoteAdminEnabled();
     String name = resource.getName();
     loader.disablePlugins(ImmutableSet.of(name));
     return ListPlugins.toPluginInfo(loader.get(name));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
index f29e36b..c6db147 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.common.PluginInfo;
-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.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.plugins.EnablePlugin.Input;
 import com.google.inject.Inject;
@@ -40,11 +40,8 @@
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input)
-      throws ResourceConflictException, MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote plugin administration is disabled");
-    }
+  public PluginInfo apply(PluginResource resource, Input input) throws RestApiException {
+    loader.checkRemoteAdminEnabled();
     String name = resource.getName();
     try {
       loader.enablePlugins(ImmutableSet.of(name));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
index 531e9ac..ee9099e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 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.TopLevelResource;
 import com.google.inject.Inject;
@@ -56,10 +56,8 @@
 
   @Override
   public Response<PluginInfo> apply(TopLevelResource resource, InstallPluginInput input)
-      throws BadRequestException, MethodNotAllowedException, IOException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote installation is disabled");
-    }
+      throws RestApiException, IOException {
+    loader.checkRemoteAdminEnabled();
     try {
       try (InputStream in = openStream(input)) {
         String pluginName = loader.installPluginFromStream(name, in);
@@ -104,7 +102,7 @@
 
     @Override
     public Response<PluginInfo> apply(PluginResource resource, InstallPluginInput input)
-        throws BadRequestException, MethodNotAllowedException, IOException {
+        throws RestApiException, IOException {
       return install.get().setName(resource.getName()).apply(TopLevelResource.INSTANCE, input);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
index d972087..954ea29 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
 import com.google.gerrit.server.PluginUser;
 import com.google.gerrit.server.cache.PersistentCacheFactory;
@@ -138,6 +139,12 @@
     return remoteAdmin;
   }
 
+  public void checkRemoteAdminEnabled() throws MethodNotAllowedException {
+    if (!remoteAdmin) {
+      throw new MethodNotAllowedException("remote plugin administration is disabled");
+    }
+  }
+
   public Plugin get(String name) {
     Plugin p = running.get(name);
     if (p != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
index 768aa86..9dbc956 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -67,11 +67,8 @@
   }
 
   @Override
-  public InstallPlugin create(TopLevelResource parent, IdString id)
-      throws ResourceNotFoundException, MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote installation is disabled");
-    }
+  public InstallPlugin create(TopLevelResource parent, IdString id) throws RestApiException {
+    loader.checkRemoteAdminEnabled();
     return install.get().setName(id.get()).setCreated(true);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
index d43a066..a9e8fd3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
@@ -211,8 +211,8 @@
     DashboardInfo info = newDashboardInfo(refName, path);
     info.project = project;
     info.definingProject = definingProject.getName();
-    String query = config.getString("dashboard", null, "title");
-    info.title = replace(project, query == null ? info.path : query);
+    String title = config.getString("dashboard", null, "title");
+    info.title = replace(project, title == null ? info.path : title);
     info.description = replace(project, config.getString("dashboard", null, "description"));
     info.foreach = config.getString("dashboard", null, "foreach");
 
@@ -238,8 +238,8 @@
     return info;
   }
 
-  private static String replace(String project, String query) {
-    return query.replace("${project}", project);
+  private static String replace(String project, String input) {
+    return input == null ? input : input.replace("${project}", project);
   }
 
   private static String defaultOf(Project proj) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index ee9c570..dde577d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -42,17 +42,19 @@
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.lang.reflect.Field;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.io.DisabledOutputStream;
-import org.joda.time.format.DateTimeFormat;
-import org.joda.time.format.DateTimeFormatter;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -65,7 +67,10 @@
 public class OutputStreamQuery {
   private static final Logger log = LoggerFactory.getLogger(OutputStreamQuery.class);
 
-  private static final DateTimeFormatter dtf = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss zzz");
+  private static final DateTimeFormatter dtf =
+      DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss zzz")
+          .withLocale(Locale.US)
+          .withZone(ZoneId.systemDefault());
 
   public enum OutputFormat {
     TEXT,
@@ -402,7 +407,7 @@
       out.print('\n');
     } else if (value instanceof Long && isDateField(field)) {
       out.print(' ');
-      out.print(dtf.print(((Long) value) * 1000L));
+      out.print(dtf.format(Instant.ofEpochSecond((Long) value)));
       out.print('\n');
     } else if (isPrimitive(value)) {
       out.print(' ');
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy
index 50c5fc3..623cfe26 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy
@@ -24,7 +24,7 @@
  * @param email
  * @param fromName
  */
-{template .Abandoned autoescape="strict" kind="text"}
+{template .Abandoned kind="text"}
   {$fromName} has abandoned this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
index c7d4699..fb8ff78 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
@@ -21,7 +21,7 @@
  * @param email
  * @param fromName
  */
-{template .AbandonedHtml autoescape="strict" kind="html"}
+{template .AbandonedHtml kind="html"}
   <p>
     {$fromName} <strong>abandoned</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy
index aa2b27d..af99569 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -21,7 +21,7 @@
  * adding a new SSH or GPG key to an account.
  * @param email
  */
-{template .AddKey autoescape="strict" kind="text"}
+{template .AddKey kind="text"}
   One or more new {$email.keyType} keys have been added to Gerrit Code Review at
   {sp}{$email.gerritHost}:
 
@@ -68,4 +68,4 @@
 
   This is a send-only email address.  Replies to this message will not be read
   or answered.
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
index 017fd6d..21161ea 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -19,7 +19,7 @@
 /**
  * @param email
  */
-{template .AddKeyHtml autoescape="strict" kind="html"}
+{template .AddKeyHtml kind="html"}
   <p>
     One or more new {$email.keyType} keys have been added to Gerrit Code Review
     at {$email.gerritHost}:
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
index 37ac126..f1d201b 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
@@ -21,7 +21,7 @@
  * that will be appended to ALL emails related to changes.
  * @param email
  */
-{template .ChangeFooter autoescape="strict" kind="text"}
+{template .ChangeFooter kind="text"}
   --{sp}
   {\n}
 
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
index 00f21db..dea6724 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
@@ -20,7 +20,7 @@
  * @param change
  * @param email
  */
-{template .ChangeFooterHtml autoescape="strict" kind="html"}
+{template .ChangeFooterHtml kind="html"}
   {if $email.changeUrl or $email.settingsUrl}
     <p>
       {if $email.changeUrl}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy
index 98de6e7..d8cffc4 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -23,6 +23,6 @@
  * @param change
  * @param shortProjectName
  */
-{template .ChangeSubject autoescape="strict" kind="text"}
+{template .ChangeSubject kind="text"}
   Change in {$shortProjectName}[{$branch.shortName}]: {$change.shortSubject}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy
index 7bedc1c..7f3062c 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy
@@ -25,7 +25,7 @@
  * @param fromName
  * @param commentFiles
  */
-{template .Comment autoescape="strict" kind="text"}
+{template .Comment kind="text"}
   {$fromName} has posted comments on this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
index 73fdfba..3998438 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
@@ -21,5 +21,5 @@
  * that will be appended to emails related to a user submitting comments on
  * changes.
  */
-{template .CommentFooter autoescape="strict" kind="text"}
+{template .CommentFooter kind="text"}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
index 7bf28e7..c54f926 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
@@ -16,5 +16,5 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .CommentFooterHtml autoescape="strict" kind="html"}
+{template .CommentFooterHtml kind="html"}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 870ad46..9b96d69 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -24,7 +24,7 @@
  * @param patchSet
  * @param patchSetCommentBlocks
  */
-{template .CommentHtml autoescape="strict" kind="html"}
+{template .CommentHtml kind="html"}
   {let $commentHeaderStyle kind="css"}
     margin-bottom: 4px;
   {/let}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
index 888ee4b..fc1d60f 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
@@ -24,7 +24,7 @@
  * @param email
  * @param fromName
  */
-{template .DeleteReviewer autoescape="strict" kind="text"}
+{template .DeleteReviewer kind="text"}
   {$fromName} has removed{sp}
   {foreach $reviewerName in $email.reviewerNames}
     {if not isFirst($reviewerName)},{sp}{/if}
@@ -41,4 +41,4 @@
     {$coverLetter}
     {\n}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
index 5faa411..74e5ee5 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
@@ -20,7 +20,7 @@
  * @param email
  * @param fromName
  */
-{template .DeleteReviewerHtml autoescape="strict" kind="html"}
+{template .DeleteReviewerHtml kind="html"}
   <p>
     {$fromName}{sp}
     <strong>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy
index b249ded..724e90d 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy
@@ -23,7 +23,7 @@
  * @param coverLetter
  * @param fromName
  */
-{template .DeleteVote autoescape="strict" kind="text"}
+{template .DeleteVote kind="text"}
   {$fromName} has removed a vote on this change.{\n}
   {\n}
   Change subject: {$change.subject}{\n}
@@ -34,4 +34,4 @@
     {$coverLetter}
     {\n}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
index 3d76ae2..06f5456 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
@@ -21,7 +21,7 @@
  * @param email
  * @param fromName
  */
-{template .DeleteVoteHtml autoescape="strict" kind="html"}
+{template .DeleteVoteHtml kind="html"}
   <p>
     {$fromName} <strong>removed a vote</strong> from this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
index 24db2fd..2b146ec 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
@@ -22,7 +22,7 @@
  * CommentFooter.
  * @param footers
  */
-{template .Footer autoescape="strict" kind="text"}
+{template .Footer kind="text"}
   {foreach $footer in $footers}
     {$footer}{\n}
   {/foreach}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
index 9f9c503..d9f13ce 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
@@ -19,7 +19,7 @@
 /**
  * @param footers
  */
-{template .FooterHtml autoescape="strict" kind="html"}
+{template .FooterHtml kind="html"}
   {\n}
   {\n}
   {foreach $footer in $footers}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
index fdc3fee..85b56ec 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
@@ -16,5 +16,5 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .HeaderHtml autoescape="strict" kind="html"}
+{template .HeaderHtml kind="html"}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy
index d483264..40924e6 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy
@@ -24,7 +24,7 @@
  * @param email
  * @param fromName
  */
-{template .Merged autoescape="strict" kind="text"}
+{template .Merged kind="text"}
   {$fromName} has submitted this change and it was merged.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
@@ -39,4 +39,4 @@
     {$email.unifiedDiff}
     {\n}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
index 927601b..08d37cc 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -21,7 +21,7 @@
  * @param email
  * @param fromName
  */
-{template .MergedHtml autoescape="strict" kind="html"}
+{template .MergedHtml kind="html"}
   <p>
     {$fromName} <strong>merged</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy
index 9f7429f..ca24d19 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -25,7 +25,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .NewChange autoescape="strict" kind="text"}
+{template .NewChange kind="text"}
   {if $email.reviewerNames}
     Hello{sp}
     {foreach $reviewerName in $email.reviewerNames}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index 8026666..676f019 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -24,7 +24,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .NewChangeHtml autoescape="strict" kind="html"}
+{template .NewChangeHtml kind="html"}
   <p>
     {if $email.reviewerNames}
       {$fromName} would like{sp}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
index b26535b..c1ac5b6 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
@@ -24,7 +24,7 @@
  * Private template to generate "View Change" buttons.
  * @param email
  */
-{template .ViewChangeButton autoescape="strict" kind="html"}
+{template .ViewChangeButton kind="html"}
   <a href="{$email.changeUrl}">View Change</a>
 {/template}
 
@@ -32,7 +32,7 @@
  * Private template to render PRE block with consistent font-sizing.
  * @param content
  */
-{template .Pre autoescape="strict" kind="html"}
+{template .Pre kind="html"}
   {let $preStyle kind="css"}
     font-family: monospace,monospace; // Use this to avoid browsers scaling down
                                       // monospace text.
@@ -56,7 +56,7 @@
  *
  * @param content
  */
-{template .WikiFormat autoescape="strict" kind="html"}
+{template .WikiFormat kind="html"}
   {let $blockquoteStyle kind="css"}
     border-left: 1px solid #aaa;
     margin: 10px 0;
@@ -90,7 +90,7 @@
 /**
  * @param diffLines
  */
-{template .UnifiedDiff autoescape="strict" kind="html"}
+{template .UnifiedDiff kind="html"}
   {let $addStyle kind="css"}
     color: hsl(120, 100%, 40%);
   {/let}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
index 2b30ae6..2886cc0 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -21,7 +21,7 @@
  * related to registering new email accounts.
  * @param email
  */
-{template .RegisterNewEmail autoescape="strict" kind="text"}
+{template .RegisterNewEmail kind="text"}
   Welcome to Gerrit Code Review at {$email.gerritHost}.{\n}
 
   {\n}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
index e41bdda..124cdf3 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -26,7 +26,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .ReplacePatchSet autoescape="strict" kind="text"}
+{template .ReplacePatchSet kind="text"}
   {if $email.reviewerNames and $fromEmail == $change.ownerEmail}
     Hello{sp}
     {foreach $reviewerName in $email.reviewerNames}
@@ -60,4 +60,4 @@
         {$patchSet.refName}
     {\n}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index 05c60a1..221a4e6 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -24,7 +24,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .ReplacePatchSetHtml autoescape="strict" kind="html"}
+{template .ReplacePatchSetHtml kind="html"}
   <p>
     {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
     to{sp}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy
index 14ae0f3..4fc6d8c 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy
@@ -24,7 +24,7 @@
  * @param email
  * @param fromName
  */
-{template .Restored autoescape="strict" kind="text"}
+{template .Restored kind="text"}
   {$fromName} has restored this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
@@ -36,4 +36,4 @@
     {$coverLetter}
     {\n}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
index ea4f615..fdc68b0 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
@@ -20,7 +20,7 @@
  * @param email
  * @param fromName
  */
-{template .RestoredHtml autoescape="strict" kind="html"}
+{template .RestoredHtml kind="html"}
   <p>
     {$fromName} <strong>restored</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy
index 7f74df9..09e32ff 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy
@@ -24,7 +24,7 @@
  * @param email
  * @param fromName
  */
-{template .Reverted autoescape="strict" kind="text"}
+{template .Reverted kind="text"}
   {$fromName} has reverted this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
index d6407e7..479eae1 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
@@ -20,7 +20,7 @@
  * @param email
  * @param fromName
  */
-{template .RevertedHtml autoescape="strict" kind="html"}
+{template .RevertedHtml kind="html"}
   <p>
     {$fromName} <strong>reverted</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy
index ca4f267..98290e9 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy
@@ -25,7 +25,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .SetAssignee autoescape="strict" kind="text"}
+{template .SetAssignee kind="text"}
   Hello{sp}
   {$email.assigneeName},
 
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
index 31cfbd6..d057ba3 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
@@ -23,7 +23,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .SetAssigneeHtml autoescape="strict" kind="html"}
+{template .SetAssigneeHtml kind="html"}
   <p>
     {$fromName} has <strong>assigned</strong> a change to{sp}
     {$email.assigneeName}.{sp}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java
index e6f36b9..0423a53 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java
@@ -20,15 +20,19 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static org.junit.Assert.assertEquals;
 
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
-import org.joda.time.DateTime;
 import org.junit.Test;
 
 public class ScheduleConfigTest {
 
   // Friday June 13, 2014 10:00 UTC
-  private static final DateTime NOW = DateTime.parse("2014-06-13T10:00:00-00:00");
+  private static final ZonedDateTime NOW =
+      LocalDateTime.of(2014, Month.JUNE, 13, 10, 0, 0).atOffset(ZoneOffset.UTC).toZonedDateTime();
 
   @Test
   public void initialDelay() throws Exception {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
index 19ad8bb..7309437 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
@@ -20,9 +20,9 @@
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.server.mail.Address;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
-import org.joda.time.DateTime;
 import org.junit.Ignore;
 
 @Ignore
@@ -85,7 +85,7 @@
     MailMessage.Builder b = MailMessage.builder();
     b.id("id");
     b.from(new Address("Foo Bar", "foo@bar.com"));
-    b.dateReceived(new DateTime());
+    b.dateReceived(Instant.now());
     b.subject("");
     return b;
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java
index 84bae96..dc25939 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java
@@ -20,8 +20,10 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.MetadataName;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Test;
 
 public class MetadataParserTest {
@@ -31,7 +33,7 @@
     // email headers of the message.
     MailMessage.Builder b = MailMessage.builder();
     b.id("");
-    b.dateReceived(new DateTime());
+    b.dateReceived(Instant.now());
     b.subject("");
 
     b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.CHANGE_NUMBER) + "123");
@@ -48,8 +50,11 @@
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
     assertThat(meta.messageType).isEqualTo("comment");
-    assertThat(meta.timestamp.getTime())
-        .isEqualTo(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+    assertThat(meta.timestamp.toInstant())
+        .isEqualTo(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
   }
 
   @Test
@@ -58,7 +63,7 @@
     // the text body of the message.
     MailMessage.Builder b = MailMessage.builder();
     b.id("");
-    b.dateReceived(new DateTime());
+    b.dateReceived(Instant.now());
     b.subject("");
 
     StringBuilder stringBuilder = new StringBuilder();
@@ -77,8 +82,11 @@
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
     assertThat(meta.messageType).isEqualTo("comment");
-    assertThat(meta.timestamp.getTime())
-        .isEqualTo(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+    assertThat(meta.timestamp.toInstant())
+        .isEqualTo(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
   }
 
   @Test
@@ -87,7 +95,7 @@
     // the HTML body of the message.
     MailMessage.Builder b = MailMessage.builder();
     b.id("");
-    b.dateReceived(new DateTime());
+    b.dateReceived(Instant.now());
     b.subject("");
 
     StringBuilder stringBuilder = new StringBuilder();
@@ -111,7 +119,10 @@
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
     assertThat(meta.messageType).isEqualTo("comment");
-    assertThat(meta.timestamp.getTime())
-        .isEqualTo(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+    assertThat(meta.timestamp.toInstant())
+        .isEqualTo(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java
index 4efa817..001d12d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java
@@ -65,7 +65,7 @@
     assertThat(have.to()).isEqualTo(want.to());
     assertThat(have.from()).isEqualTo(want.from());
     assertThat(have.cc()).isEqualTo(want.cc());
-    assertThat(have.dateReceived().getMillis()).isEqualTo(want.dateReceived().getMillis());
+    assertThat(have.dateReceived()).isEqualTo(want.dateReceived());
     assertThat(have.additionalHeaders()).isEqualTo(want.additionalHeaders());
     assertThat(have.subject()).isEqualTo(want.subject());
     assertThat(have.textContent()).isEqualTo(want.textContent());
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
index be8d882..eb4d180 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
@@ -16,8 +16,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /**
@@ -82,7 +83,10 @@
         .htmlContent("<div dir=\"ltr\">Contains unwanted attachment</div>")
         .subject("Test Subject")
         .addAdditionalHeader("MIME-Version: 1.0")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
     return expect.build();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
index affa3bd..91dc6f1 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
@@ -16,8 +16,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /** Tests parsing a Base64 encoded subject. */
@@ -58,7 +59,10 @@
         .addTo(new Address("ekempin", "ekempin@google.com"))
         .textContent(textContent)
         .subject("\uD83D\uDE1B test")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
     return expect.build();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
index 487e9dd..756581f 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
@@ -16,8 +16,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /** Tests a message containing mime/alternative (text + html) content. */
@@ -98,7 +99,10 @@
         .htmlContent(unencodedHtmlContent)
         .subject("Change in gerrit[master]: Implement receiver class structure and bindings")
         .addAdditionalHeader("MIME-Version: 1.0")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
     return expect.build();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
index 9f2af0d..3fafd4b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
@@ -15,8 +15,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /** Tests that non-UTF8 encodings are handled correctly. */
@@ -62,7 +63,10 @@
         .addTo(new Address("ekempin", "ekempin@google.com"))
         .textContent(textContent)
         .subject("\uD83D\uDE1B test")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
     return expect.build();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
index 2c17859..2dc48b5 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
@@ -16,8 +16,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /** Tests parsing a quoted printable encoded subject */
@@ -59,7 +60,10 @@
         .addTo(new Address("ekempin", "ekempin@google.com"))
         .textContent(textContent)
         .subject("âme vulgaire")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant());
     return expect.build();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
index ce833d5..aa5b78a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
@@ -16,8 +16,9 @@
 
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.receive.MailMessage;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import org.junit.Ignore;
 
 /** Tests parsing a simple text message with different headers. */
@@ -124,7 +125,10 @@
         .addCc(new Address("Patrick Hiesel", "hiesel@google.com"))
         .textContent(textContent)
         .subject("Change in gerrit[master]: (Re)enable voting buttons for merged changes")
-        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC))
+        .dateReceived(
+            LocalDateTime.of(2016, Month.OCTOBER, 25, 9, 11, 35)
+                .atOffset(ZoneOffset.UTC)
+                .toInstant())
         .addAdditionalHeader(
             "Authentication-Results: mx.google.com; dkim=pass header.i=@google.com;")
         .addAdditionalHeader(
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
index 90e6800..33e1005 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
@@ -46,13 +46,14 @@
 import com.google.gwtorm.protobuf.CodecFactory;
 import com.google.gwtorm.protobuf.ProtobufCodec;
 import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.Month;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.TimeZone;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -67,6 +68,7 @@
       CodecFactory.encoder(PatchSetApproval.class);
   private static final ProtobufCodec<PatchLineComment> PATCH_LINE_COMMENT_CODEC =
       CodecFactory.encoder(PatchLineComment.class);
+  private static final String TIMEZONE_ID = "US/Eastern";
 
   private String systemTimeZoneProperty;
   private TimeZone systemTimeZone;
@@ -76,10 +78,9 @@
 
   @Before
   public void setUp() {
-    String tz = "US/Eastern";
-    systemTimeZoneProperty = System.setProperty("user.timezone", tz);
+    systemTimeZoneProperty = System.setProperty("user.timezone", TIMEZONE_ID);
     systemTimeZone = TimeZone.getDefault();
-    TimeZone.setDefault(TimeZone.getTimeZone(tz));
+    TimeZone.setDefault(TimeZone.getTimeZone(TIMEZONE_ID));
     long maxMs = ChangeRebuilderImpl.MAX_WINDOW_MS;
     assertThat(maxMs).isGreaterThan(1000L);
     TestTimeUtil.resetWithClockStep(maxMs * 2, MILLISECONDS);
@@ -1517,8 +1518,11 @@
     PatchSetApproval a2 = clone(a1);
     a2.setGranted(
         new Timestamp(
-            new DateTime(1900, 1, 1, 0, 0, 0, DateTimeZone.forTimeZone(TimeZone.getDefault()))
-                .getMillis()));
+            LocalDate.of(1900, Month.JANUARY, 1)
+                .atStartOfDay()
+                .atZone(ZoneId.of(TIMEZONE_ID))
+                .toInstant()
+                .toEpochMilli()));
 
     // Both are ReviewDb, exact match is required.
     ChangeBundle b1 =
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 1e722fc..7234acc 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
@@ -1232,7 +1232,7 @@
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
-    long startMs = TestTimeUtil.START.getMillis();
+    long startMs = TestTimeUtil.START.toEpochMilli();
     Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
 
@@ -1259,7 +1259,7 @@
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
-    long startMs = TestTimeUtil.START.getMillis();
+    long startMs = TestTimeUtil.START.toEpochMilli();
     Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
@@ -1281,7 +1281,7 @@
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
-    long startMs = TestTimeUtil.START.getMillis();
+    long startMs = TestTimeUtil.START.toEpochMilli();
     Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
index dd44cb9ae..5bbe3b6 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
@@ -17,18 +17,21 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
+import com.google.gerrit.common.TimeUtil;
 import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeUtils;
-import org.joda.time.DateTimeUtils.MillisProvider;
-import org.joda.time.DateTimeZone;
 
 /** Static utility methods for dealing with dates and times in tests. */
 public class TestTimeUtil {
-  public static final DateTime START =
-      new DateTime(2009, 9, 30, 17, 0, 0, DateTimeZone.forOffsetHours(-4));
+  public static final Instant START =
+      LocalDateTime.of(2009, Month.SEPTEMBER, 30, 17, 0, 0)
+          .atOffset(ZoneOffset.ofHours(-4))
+          .toInstant();
 
   private static Long clockStepMs;
   private static AtomicLong clockMs;
@@ -43,7 +46,7 @@
    */
   public static synchronized void resetWithClockStep(long clockStep, TimeUnit clockStepUnit) {
     // Set an arbitrary start point so tests are more repeatable.
-    clockMs = new AtomicLong(START.getMillis());
+    clockMs = new AtomicLong(START.toEpochMilli());
     setClockStep(clockStep, clockStepUnit);
   }
 
@@ -56,13 +59,7 @@
   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);
-          }
-        });
+    TimeUtil.setCurrentMillisSupplier(() -> clockMs.getAndAdd(clockStepMs));
   }
 
   /**
@@ -89,7 +86,7 @@
   /** Reset the clock to use the actual system clock. */
   public static synchronized void useSystemTime() {
     clockMs = null;
-    DateTimeUtils.setCurrentMillisSystem();
+    TimeUtil.resetCurrentMillisSupplier();
   }
 
   private TestTimeUtil() {}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
new file mode 100644
index 0000000..7e32615
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.sshd.commands;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.plugins.PluginLoader;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+public abstract class PluginAdminSshCommand extends SshCommand {
+  @Inject protected PluginLoader loader;
+
+  abstract void doRun() throws UnloggedFailure;
+
+  @Override
+  protected final void run() throws UnloggedFailure {
+    if (!loader.isRemoteAdminEnabled()) {
+      throw die("remote plugin administration is disabled");
+    }
+    doRun();
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
index d7c8f3a..baaf715 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
@@ -17,29 +17,18 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.plugins.PluginInstallException;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "enable", description = "Enable plugins", runsAt = MASTER_OR_SLAVE)
-final class PluginEnableCommand extends SshCommand {
+final class PluginEnableCommand extends PluginAdminSshCommand {
   @Argument(index = 0, metaVar = "NAME", required = true, usage = "plugin(s) to enable")
   List<String> names;
 
-  @Inject private PluginLoader loader;
-
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote plugin administration is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (names != null && !names.isEmpty()) {
       try {
         loader.enablePlugins(Sets.newHashSet(names));
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
index 820052c..337eadb 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
@@ -17,13 +17,8 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.plugins.PluginInstallException;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -33,9 +28,8 @@
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "install", description = "Install/Add a plugin", runsAt = MASTER_OR_SLAVE)
-final class PluginInstallCommand extends SshCommand {
+final class PluginInstallCommand extends PluginAdminSshCommand {
   @Option(
     name = "--name",
     aliases = {"-n"},
@@ -51,14 +45,9 @@
   @Argument(index = 0, metaVar = "-|URL", usage = "JAR to load")
   private String source;
 
-  @Inject private PluginLoader loader;
-
   @SuppressWarnings("resource")
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote installation is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (Strings.isNullOrEmpty(source)) {
       throw die("Argument \"-|URL\" is required");
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
index 0f2c912..86a74d1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
@@ -16,30 +16,19 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.plugins.InvalidPluginException;
 import com.google.gerrit.server.plugins.PluginInstallException;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "reload", description = "Reload/Restart plugins", runsAt = MASTER_OR_SLAVE)
-final class PluginReloadCommand extends SshCommand {
+final class PluginReloadCommand extends PluginAdminSshCommand {
   @Argument(index = 0, metaVar = "NAME", usage = "plugins to reload/restart")
   private List<String> names;
 
-  @Inject private PluginLoader loader;
-
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote plugin administration is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (names == null || names.isEmpty()) {
       loader.rescan();
     } else {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
index 8a38739..0119349b 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
@@ -17,28 +17,17 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "remove", description = "Disable plugins", runsAt = MASTER_OR_SLAVE)
-final class PluginRemoveCommand extends SshCommand {
+final class PluginRemoveCommand extends PluginAdminSshCommand {
   @Argument(index = 0, metaVar = "NAME", required = true, usage = "plugin to remove")
   List<String> names;
 
-  @Inject private PluginLoader loader;
-
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote plugin administration is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (names != null && !names.isEmpty()) {
       loader.disablePlugins(Sets.newHashSet(names));
     }
diff --git a/gerrit-war/pom.xml b/gerrit-war/pom.xml
index 2dd6ba6..c43c098 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.15-rc0</version>
+  <version>2.16-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/lib/elasticsearch/BUILD b/lib/elasticsearch/BUILD
index c40925e..18c62af 100644
--- a/lib/elasticsearch/BUILD
+++ b/lib/elasticsearch/BUILD
@@ -8,13 +8,13 @@
         ":compress-lzf",
         ":hppc",
         ":jna",
+        ":joda-time",
         ":jsr166e",
         ":netty",
         ":t-digest",
         "//lib/jackson:jackson-core",
         "//lib/jackson:jackson-dataformat-cbor",
         "//lib/jackson:jackson-dataformat-smile",
-        "//lib/joda:joda-time",
         "//lib/lucene:lucene-codecs",
         "//lib/lucene:lucene-highlighter",
         "//lib/lucene:lucene-join",
@@ -48,6 +48,19 @@
 )
 
 java_library(
+    name = "joda-time",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@joda_time//jar"],
+    runtime_deps = ["joda-convert"],
+)
+
+java_library(
+    name = "joda-convert",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@joda_convert//jar"],
+)
+
+java_library(
     name = "compress-lzf",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//lib/elasticsearch:__pkg__"],
diff --git a/lib/joda/BUILD b/lib/joda/BUILD
deleted file mode 100644
index e1a1924..0000000
--- a/lib/joda/BUILD
+++ /dev/null
@@ -1,13 +0,0 @@
-java_library(
-    name = "joda-time",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@joda_time//jar"],
-    runtime_deps = ["joda-convert"],
-)
-
-java_library(
-    name = "joda-convert",
-    data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@joda_convert//jar"],
-)
diff --git a/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.html b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.html
new file mode 100644
index 0000000..6bf2211
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.html
@@ -0,0 +1,32 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+
+<dom-module id="gr-project-command">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        margin-bottom: 2em;
+      }
+    </style>
+    <h3>[[title]]</h3>
+    <gr-button on-tap="_onCommandTap">[[title]]</gr-button>
+  </template>
+  <script src="gr-project-command.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.js b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.js
new file mode 100644
index 0000000..48789b0
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command.js
@@ -0,0 +1,34 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-project-command',
+
+    properties: {
+      title: String,
+    },
+
+    /**
+     * Fired when command button is tapped.
+     *
+     * @event command-tap
+     */
+
+    _onCommandTap() {
+      this.dispatchEvent(new CustomEvent('command-tap', {bubbles: true}));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command_test.html b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command_test.html
new file mode 100644
index 0000000..8fae4f8
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-command/gr-project-command_test.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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-project-command</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-project-command.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-project-command></gr-project-command>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-project-command tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('dispatched command-tap on button tap', done => {
+      element.addEventListener('command-tap', () => {
+        done();
+      });
+      MockInteractions.tap(
+          Polymer.dom(element.root).querySelector('gr-button'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html
index 6c0908a..f43403a 100644
--- a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html
+++ b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html
@@ -19,10 +19,12 @@
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-create-change-dialog/gr-create-change-dialog.html">
+<link rel="import" href="../gr-project-command/gr-project-command.html">
 
 <dom-module id="gr-project-commands">
   <template>
@@ -47,24 +49,23 @@
       <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
         <h2 id="options">Command</h2>
         <div id="form">
-          <fieldset>
-            <h3 id="createChange">Create Change</h3>
-            <fieldset>
-              <gr-button id="createNewChange" on-tap="_createNewChange">
-                Create Change
-              </gr-button>
-            </fieldset>
-            <h3 id="runGC" hidden$="[[!_projectConfig.actions.gc.enabled]]">
-                Run GC
-            </h3>
-            <fieldset>
-              <gr-button
-                  on-tap="_handleRunningGC"
-                  hidden$="[[!_projectConfig.actions.gc.enabled]]">
-                Run GC
-              </gr-button>
-            </fieldset>
-          </fieldset>
+          <gr-project-command
+              title="Create Change"
+              on-command-tap="_createNewChange">
+          </gr-project-command>
+
+          <gr-project-command
+              title="Run GC"
+              hidden$="[[!_projectConfig.actions.gc.enabled]]"
+              on-command-tap="_handleRunningGC">
+          </gr-project-command>
+
+          <gr-endpoint-decorator name="project-command">
+            <gr-endpoint-param name="config" value="[[_projectConfig]]">
+            </gr-endpoint-param>
+            <gr-endpoint-param name="projectName" value="[[project]]">
+            </gr-endpoint-param>
+          </gr-endpoint-decorator>
         </div>
       </div>
     </main>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html
index c424808..62870df 100644
--- a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html
@@ -69,6 +69,7 @@
       element = fixture('basic');
       element.detailType = 'branches';
       counter = 0;
+      sandbox.stub(page, 'show');
     });
 
     teardown(() => {
@@ -314,6 +315,7 @@
       element = fixture('basic');
       element.detailType = 'tags';
       counter = 0;
+      sandbox.stub(page, 'show');
     });
 
     teardown(() => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 6c5bad3..750db92 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -73,7 +73,7 @@
     },
 
     observers: [
-      '_userChanged(params.user)',
+      '_paramsChanged(params.*)',
     ],
 
     behaviors: [
@@ -95,20 +95,28 @@
       return 'Dashboard for ' + user;
     },
 
-    /**
-     * Allows a refresh if menu item is selected again.
-     */
-    _userChanged(user) {
-      if (!user) { return; }
+    _paramsChanged(paramsChangeRecord) {
+      const params = paramsChangeRecord.base;
+
+      if (!params.user && !params.sections) {
+        return;
+      }
+
+      const user = params.user || 'self';
+      const sections = (params.sections || DEFAULT_SECTIONS).filter(
+          section => (user === 'self' || !section.selfOnly));
+      const title = params.title || this._computeTitle(user);
 
       // NOTE: This method may be called before attachment. Fire title-change
       // in an async so that attachment to the DOM can take place first.
-      this.async(
-          () => this.fire('title-change', {title: this._computeTitle(user)}));
+      this.async(() => this.fire('title-change', {title}));
+
+      // Return if params indicate no longer in view.
+      if (!user && sections === DEFAULT_SECTIONS) {
+        return;
+      }
 
       this._loading = true;
-      const sections = this._sectionMetadata.filter(
-          section => (user === 'self' || !section.selfOnly));
       const queries =
           sections.map(
               section => this._dashboardQueryForSection(section, user));
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index 2edf26f..40376ad 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -64,17 +64,18 @@
     });
 
     test('viewing another user\'s dashboard omits selfOnly sections', () => {
-      element._sectionMetadata = [
-        {query: '1'},
-        {query: '2', selfOnly: true},
-      ];
-
-      element.params = {user: 'self'};
+      element.params = {
+        sections: [
+          {query: '1'},
+          {query: '2', selfOnly: true},
+        ],
+        user: 'self',
+      };
       flushAsynchronousOperations();
       assert.isTrue(
           getChangesStub.calledWith(null, ['1', '2'], null, element.options));
 
-      element.params = {user: 'user'};
+      element.set('params.user', 'user');
       flushAsynchronousOperations();
       assert.isTrue(
           getChangesStub.calledWith(null, ['1'], null, element.options));
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index cb3d58a..fd07d4e 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -46,12 +46,11 @@
         border-top: 1px solid #ddd;
         display: flex;
         min-height: 3.2em;
-        padding: .5em calc(var(--default-horizontal-margin) / 2);
+        padding: .5em var(--default-horizontal-margin);
       }
       .patchInfo-header-wrapper {
         align-items: center;
         display: flex;
-        margin: 0 .25em;
         width: 100%;
       }
       .patchInfo-left {
@@ -113,7 +112,7 @@
       .separator {
         background-color: rgba(0, 0, 0, .3);
         height: 1.5em;
-        margin: 0 .4em;
+        margin: 0 .6em;
         width: 1px;
       }
       .separator.transparent {
@@ -141,7 +140,7 @@
     <div class$="patchInfo-header [[_computeEditLoadedClass(editLoaded)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]">
       <div class="patchInfo-header-wrapper">
         <div class="patchInfo-left">
-          <span class="label">Files</span>
+          <h3 class="label">Files</h3>
           <gr-patch-range-select
               id="rangeSelect"
               comments="[[comments]]"
@@ -190,7 +189,6 @@
       </div>
     </div>
     <div class="fileList-header">
-      <div>Files</div>
       <div class="rightControls">
         <template is="dom-if"
             if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index 1a164b6..f80ff0a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -23,6 +23,7 @@
 <link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
 <link rel="import" href="../../diff/gr-diff/gr-diff.html">
 <link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
+<link rel="import" href="../../edit/gr-edit-file-controls/gr-edit-file-controls.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
@@ -42,7 +43,7 @@
         border-top: 1px solid #ddd;
         display: flex;
         height: 2.25em;
-        padding: 0 calc(var(--default-horizontal-margin) / 2 + .25em);
+        padding: 0 var(--default-horizontal-margin);
       }
       :host(.loading) .row {
         opacity: .5;
@@ -50,6 +51,12 @@
       :host(.editLoaded) .hideOnEdit {
         display: none;
       }
+      .showOnEdit {
+        display: none;
+      }
+      :host(.editLoaded) .showOnEdit {
+        display: initial;
+      }
       .reviewed,
       .status {
         align-items: center;
@@ -184,6 +191,10 @@
         display: initial;
         opacity: 100;
       }
+      .editFileControls {
+        margin-left: 1em;
+        width: 4em;
+      }
       @media screen and (max-width: 50em) {
         .desktop {
           display: none;
@@ -299,6 +310,12 @@
               <span class="markReviewed" title="Mark as reviewed (shortcut: r)">[[_computeReviewedText(file.isReviewed)]]</span>
             </label>
           </div>
+          <div class="editFileControls showOnEdit">
+            <gr-edit-file-controls
+                class$="[[_computeClass('', file.__path)]]"
+                file-path="[[file.__path]]"
+                on-edit-tap="_handleEditTap"></gr-edit-file-controls>
+          </div>
         </div>
         <template is="dom-if"
             if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
@@ -338,6 +355,7 @@
       </div>
       <!-- Empty div here exists to keep spacing in sync with file rows. -->
       <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]" hidden></div>
+      <div class="editFileControls showOnEdit"></div>
     </div>
     <div
         class$="row totalChanges [[_computeExpandInlineClass(_userPrefs)]]"
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 11a4a29..70f1d4d 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -926,5 +926,10 @@
     _computeReviewedText(isReviewed) {
       return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
     },
+
+    _handleEditTap(e) {
+      const url = Gerrit.Nav.getEditUrlForDiff(this.change, e.detail.path);
+      Gerrit.Nav.navigateToRelativeUrl(url);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index b80a20f..b01c105 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -1238,6 +1238,20 @@
         });
       });
     });
+
+    test('editing actions', () => {
+      element.editLoaded = true;
+      element.change = {_number: '42', project: 'test'};
+      const navStub = sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
+      const editControls =
+          Polymer.dom(element.root).querySelectorAll('.row:not(.header)')
+            .map(row => row.querySelector('gr-edit-file-controls'));
+
+      // Commit message should not have edit controls.
+      assert.isTrue(editControls[0].classList.contains('invisible'));
+      MockInteractions.tap(editControls[1].$.edit);
+      assert.isTrue(navStub.called);
+    });
   });
   a11ySuite('basic');
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index 0fa6f4d..ab494b4 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -60,7 +60,7 @@
       .separator {
         background-color: rgba(0, 0, 0, .3);
         height: 1.5em;
-        margin: 0 .4em;
+        margin: 0 .6em;
         width: 1px;
       }
       .separator.transparent {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index 4b02c3d..068cacc 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -56,6 +56,13 @@
         padding: .5em .75em;
         width: 100%;
       }
+      .actions {
+        display: flex;
+        justify-content: space-between;
+      }
+      .actions gr-button {
+        margin-left: 1em;
+      }
       .peopleContainer,
       .labelsContainer {
         flex-shrink: 0;
@@ -135,9 +142,6 @@
       #savingLabel.saving {
         display: inline;
       }
-      #cancelButton {
-        float: right;
-      }
       @media screen and (max-width: 50em) {
         :host {
           max-height: none;
@@ -259,33 +263,40 @@
           Saving comments...
         </span>
       </section>
-      <section>
-        <gr-button
-            primary
-            disabled="[[_computeSendButtonDisabled(knownLatestState, _sendButtonLabel, diffDrafts, draft, _reviewersMutated, _labelsChanged, _includeComments)]]"
-            class="action send"
-            on-tap="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
-        <template is="dom-if" if="[[canBeStarted]]">
+      <section class="actions">
+        <div class="left">
+          <span
+              id="checkingStatusLabel"
+              hidden$="[[!_isState(knownLatestState, 'checking')]]">
+            Checking whether patch [[patchNum]] is latest...
+          </span>
+          <span
+              id="notLatestLabel"
+              hidden$="[[!_isState(knownLatestState, 'not-latest')]]">
+            Patch [[patchNum]] is not latest.
+            <gr-button link on-tap="_reload">Reload</gr-button>
+          </span>
+        </div>
+        <div class="right">
           <gr-button
-              disabled="[[_isState(knownLatestState, 'not-latest')]]"
-              class="action save"
-              on-tap="_saveTapHandler">Save</gr-button>
-        </template>
-        <span
-            id="checkingStatusLabel"
-            hidden$="[[!_isState(knownLatestState, 'checking')]]">
-          Checking whether patch [[patchNum]] is latest...
-        </span>
-        <span
-            id="notLatestLabel"
-            hidden$="[[!_isState(knownLatestState, 'not-latest')]]">
-          Patch [[patchNum]] is not latest.
-          <gr-button link on-tap="_reload">Reload</gr-button>
-        </span>
-        <gr-button
-            id="cancelButton"
-            class="action cancel"
-            on-tap="_cancelTapHandler">Cancel</gr-button>
+              link
+              id="cancelButton"
+              class="action cancel"
+              on-tap="_cancelTapHandler">Cancel</gr-button>
+          <gr-button
+              link
+              primary
+              disabled="[[_computeSendButtonDisabled(knownLatestState, _sendButtonLabel, diffDrafts, draft, _reviewersMutated, _labelsChanged, _includeComments)]]"
+              class="action send"
+              on-tap="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
+          <template is="dom-if" if="[[canBeStarted]]">
+            <gr-button
+                link
+                disabled="[[_isState(knownLatestState, 'not-latest')]]"
+                class="action save"
+                on-tap="_saveTapHandler">Save</gr-button>
+          </template>
+        </div>
       </section>
     </div>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
index b03f2e5..8453da9 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -57,6 +57,7 @@
       console.warn('Use of uninitialized routing');
     };
 
+    const EDIT_PATCHNUM = 'edit';
     const PARENT_PATCHNUM = 'PARENT';
 
     window.Gerrit.Nav = {
@@ -277,6 +278,7 @@
           changeNum,
           project,
           path,
+          patchNum: EDIT_PATCHNUM,
         });
       },
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 5b9f4f6..708cb17 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -126,6 +126,16 @@
    */
   const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
 
+  /**
+   * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
+   */
+  const PLUS_PATTERN = /\+/g;
+
+  /**
+   * Pattern to recognize leading '?' in window.location.search, for stripping.
+   */
+  const QUESTION_PATTERN = /^\?*/;
+
   // Polymer makes `app` intrinsically defined on the window by virtue of the
   // custom element having the id "app", but it is made explicit here.
   const app = document.querySelector('#app');
@@ -181,8 +191,9 @@
     _generateUrl(params) {
       const base = this.getBaseUrl();
       let url = '';
+      const Views = Gerrit.Nav.View;
 
-      if (params.view === Gerrit.Nav.View.SEARCH) {
+      if (params.view === Views.SEARCH) {
         const operators = [];
         if (params.owner) {
           operators.push('owner:' + this.encodeURL(params.owner, false));
@@ -213,7 +224,7 @@
           }
         }
         url = '/q/' + operators.join('+');
-      } else if (params.view === Gerrit.Nav.View.CHANGE) {
+      } else if (params.view === Views.CHANGE) {
         let range = this._getPatchRangeExpression(params);
         if (range.length) { range = '/' + range; }
         if (params.project) {
@@ -221,13 +232,30 @@
         } else {
           url = `/c/${params.changeNum}${range}`;
         }
-      } else if (params.view === Gerrit.Nav.View.DASHBOARD) {
-        url = `/dashboard/${params.user || 'self'}`;
-      } else if (params.view === Gerrit.Nav.View.DIFF) {
+      } else if (params.view === Views.DASHBOARD) {
+        if (params.sections) {
+          // Custom dashboard.
+          const queryParams = params.sections.map(section => {
+            return encodeURIComponent(section.name) + '=' +
+                encodeURIComponent(section.query);
+          });
+          if (params.title) {
+            queryParams.push('title=' + encodeURIComponent(params.title));
+          }
+          const user = params.user ? params.user : '';
+          url = `/dashboard/${user}?${queryParams.join('&')}`;
+        } else {
+          // User dashboard.
+          url = `/dashboard/${params.user || 'self'}`;
+        }
+      } else if (params.view === Views.DIFF || params.view === Views.EDIT) {
         let range = this._getPatchRangeExpression(params);
         if (range.length) { range = '/' + range; }
 
         let suffix = `${range}/${this.encodeURL(params.path, true)}`;
+
+        if (params.view === Views.EDIT) { suffix += ',edit'; }
+
         if (params.lineNum) {
           suffix += '#';
           if (params.leftSide) { suffix += 'b'; }
@@ -239,9 +267,6 @@
         } else {
           url = `/c/${params.changeNum}${suffix}`;
         }
-        if (params.edit) {
-          url += ',edit';
-        }
       } else {
         throw new Error('Can\'t generate');
       }
@@ -593,19 +618,101 @@
       });
     },
 
-    _handleDashboardRoute(data) {
-      if (!data.params[0]) {
-        this._redirect('/dashboard/self');
-        return;
+    /**
+     * Decode an application/x-www-form-urlencoded string.
+     *
+     * @param {string} qs The application/x-www-form-urlencoded string.
+     * @return {string} The decoded string.
+     */
+    _decodeQueryString(qs) {
+      return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
+    },
+
+    /**
+     * Parse a query string (e.g. window.location.search) into an array of
+     * name/value pairs.
+     *
+     * @param {string} qs The application/x-www-form-urlencoded query string.
+     * @return {!Array<!Array<string>>} An array of name/value pairs, where each
+     *     element is a 2-element array.
+     */
+    _parseQueryString(qs) {
+      qs = qs.replace(QUESTION_PATTERN, '');
+      if (!qs) {
+        return [];
+      }
+      const params = [];
+      qs.split('&').forEach(param => {
+        const idx = param.indexOf('=');
+        let name;
+        let value;
+        if (idx < 0) {
+          name = this._decodeQueryString(param);
+          value = '';
+        } else {
+          name = this._decodeQueryString(param.substring(0, idx));
+          value = this._decodeQueryString(param.substring(idx + 1));
+        }
+        if (name) {
+          params.push([name, value]);
+        }
+      });
+      return params;
+    },
+
+    /**
+     * Handle dashboard routes. These may be user, custom, or project
+     * dashboards.
+     *
+     * @param {!Object} data The parsed route data.
+     * @param {string=} opt_qs Optional query string associated with the route.
+     *     If not given, window.location.search is used. (Used by tests).
+     */
+    _handleDashboardRoute(data, opt_qs) {
+      // opt_qs may be provided by a test, and it may have a falsy value
+      const qs = opt_qs !== undefined ? opt_qs : window.location.search;
+      const queryParams = this._parseQueryString(qs);
+      let title = 'Custom Dashboard';
+      const titleParam = queryParams.find(
+          elem => elem[0].toLowerCase() === 'title');
+      if (titleParam) {
+        title = titleParam[1];
+      }
+      const sectionParams = queryParams.filter(
+          elem => elem[0] && elem[1] && elem[0].toLowerCase() !== 'title');
+      const sections = sectionParams.map(elem => {
+        return {
+          name: elem[0],
+          query: elem[1],
+        };
+      });
+
+      if (sections.length > 0) {
+        // Custom dashboard view.
+        this._setParams({
+          view: Gerrit.Nav.View.DASHBOARD,
+          user: data.params[0] || 'self',
+          sections,
+          title,
+        });
+        return Promise.resolve();
       }
 
+      if (!data.params[0] && sections.length === 0) {
+        // Redirect /dashboard/ -> /dashboard/self.
+        this._redirect('/dashboard/self');
+        return Promise.resolve();
+      }
+
+      // User dashboard. We require viewing user to be logged in, else we
+      // redirect to login for self dashboard or simple owner search for
+      // other user dashboard.
       return this.$.restAPI.getLoggedIn().then(loggedIn => {
         if (!loggedIn) {
           if (data.params[0].toLowerCase() === 'self') {
             this._redirectToLogin(data.canonicalPath);
           } else {
-            // TODO: encode user or use _generateUrl.
-            this._redirect('/q/owner:' + data.params[0]);
+            this._redirect('/q/owner:' + encodeURIComponent(data.params[0]));
           }
         } else {
           this._setParams({
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index ca00e19..4aae65f 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -280,6 +280,17 @@
             '/c/test/+/42/2/file.cpp#b123');
       });
 
+      test('edit', () => {
+        const params = {
+          view: Gerrit.Nav.View.EDIT,
+          changeNum: '42',
+          project: 'test',
+          path: 'x+y/path.cpp',
+        };
+        assert.equal(element._generateUrl(params),
+            '/c/test/+/42/x%252By/path.cpp,edit');
+      });
+
       test('_getPatchRangeExpression', () => {
         const params = {};
         let actual = element._getPatchRangeExpression(params);
@@ -297,6 +308,48 @@
         actual = element._getPatchRangeExpression(params);
         assert.equal(actual, '2..');
       });
+
+      suite('dashboard', () => {
+        test('self dashboard', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+          };
+          assert.equal(element._generateUrl(params), '/dashboard/self');
+        });
+
+        test('user dashboard', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            user: 'user',
+          };
+          assert.equal(element._generateUrl(params), '/dashboard/user');
+        });
+
+        test('custom self dashboard, no title', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            sections: [
+              {name: 'section 1', query: 'query 1'},
+              {name: 'section 2', query: 'query 2'},
+            ],
+          };
+          assert.equal(
+              element._generateUrl(params),
+              '/dashboard/?section%201=query%201&section%202=query%202');
+        });
+
+        test('custom user dashboard, with title', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            user: 'user',
+            sections: [{name: 'name', query: 'query'}],
+            title: 'custom dashboard',
+          };
+          assert.equal(
+              element._generateUrl(params),
+              '/dashboard/user?name=query&title=custom%20dashboard');
+        });
+      });
     });
 
     suite('param normalization', () => {
@@ -514,7 +567,7 @@
           assert.isFalse(redirectStub.called);
         });
 
-        test('redirects to dahsboard if logged in', () => {
+        test('redirects to dashboard if logged in', () => {
           sandbox.stub(element.$.restAPI, 'getLoggedIn')
               .returns(Promise.resolve(true));
           const data = {
@@ -625,35 +678,31 @@
         });
 
         test('no user specified', () => {
-          const data = {canonicalPath: '/dashboard', params: {}};
-          const result = element._handleDashboardRoute(data);
-          assert.isNotOk(result);
-          assert.isFalse(setParamsStub.called);
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isTrue(redirectStub.called);
-          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+          return element._handleDashboardRoute(data, '').then(() => {
+            assert.isFalse(setParamsStub.called);
+            assert.isFalse(redirectToLoginStub.called);
+            assert.isTrue(redirectStub.called);
+            assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+          });
         });
 
-        test('own dahsboard but signed out redirects to login', () => {
+        test('own dashboard but signed out redirects to login', () => {
           sandbox.stub(element.$.restAPI, 'getLoggedIn')
               .returns(Promise.resolve(false));
-          const data = {canonicalPath: '/dashboard', params: {0: 'seLF'}};
-          const result = element._handleDashboardRoute(data);
-          assert.isOk(result);
-          return result.then(() => {
+          const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
+          return element._handleDashboardRoute(data, '').then(() => {
             assert.isTrue(redirectToLoginStub.calledOnce);
             assert.isFalse(redirectStub.called);
             assert.isFalse(setParamsStub.called);
           });
         });
 
-        test('non-self dahsboard but signed out does not redirect', () => {
+        test('non-self dashboard but signed out does not redirect', () => {
           sandbox.stub(element.$.restAPI, 'getLoggedIn')
               .returns(Promise.resolve(false));
-          const data = {canonicalPath: '/dashboard', params: {0: 'foo'}};
-          const result = element._handleDashboardRoute(data);
-          assert.isOk(result);
-          return result.then(() => {
+          const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
+          return element._handleDashboardRoute(data, '').then(() => {
             assert.isFalse(redirectToLoginStub.called);
             assert.isFalse(setParamsStub.called);
             assert.isTrue(redirectStub.calledOnce);
@@ -661,13 +710,11 @@
           });
         });
 
-        test('dahsboard while signed in sets params', () => {
+        test('dashboard while signed in sets params', () => {
           sandbox.stub(element.$.restAPI, 'getLoggedIn')
               .returns(Promise.resolve(true));
-          const data = {canonicalPath: '/dashboard', params: {0: 'foo'}};
-          const result = element._handleDashboardRoute(data);
-          assert.isOk(result);
-          return result.then(() => {
+          const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
+          return element._handleDashboardRoute(data, '').then(() => {
             assert.isFalse(redirectToLoginStub.called);
             assert.isFalse(redirectStub.called);
             assert.isTrue(setParamsStub.calledOnce);
@@ -677,6 +724,42 @@
             });
           });
         });
+
+        test('custom dashboard without title', () => {
+          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+          return element._handleDashboardRoute(data, '?a=b&c&d=e').then(() => {
+            assert.isFalse(redirectToLoginStub.called);
+            assert.isFalse(redirectStub.called);
+            assert.isTrue(setParamsStub.calledOnce);
+            assert.deepEqual(setParamsStub.lastCall.args[0], {
+              view: Gerrit.Nav.View.DASHBOARD,
+              user: 'self',
+              sections: [
+                {name: 'a', query: 'b'},
+                {name: 'd', query: 'e'},
+              ],
+              title: 'Custom Dashboard',
+            });
+          });
+        });
+
+        test('custom dashboard with title', () => {
+          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+          return element._handleDashboardRoute(data, '?a=b&c&d=&=e&title=t')
+              .then(() => {
+                assert.isFalse(redirectToLoginStub.called);
+                assert.isFalse(redirectStub.called);
+                assert.isTrue(setParamsStub.calledOnce);
+                assert.deepEqual(setParamsStub.lastCall.args[0], {
+                  view: Gerrit.Nav.View.DASHBOARD,
+                  user: 'self',
+                  sections: [
+                    {name: 'a', query: 'b'},
+                  ],
+                  title: 't',
+                });
+              });
+        });
       });
 
       suite('group routes', () => {
@@ -1170,5 +1253,31 @@
         });
       });
     });
+
+    suite('_parseQueryString', () => {
+      test('empty queries', () => {
+        assert.deepEqual(element._parseQueryString(''), []);
+        assert.deepEqual(element._parseQueryString('?'), []);
+        assert.deepEqual(element._parseQueryString('??'), []);
+        assert.deepEqual(element._parseQueryString('&&&'), []);
+      });
+
+      test('url decoding', () => {
+        assert.deepEqual(element._parseQueryString('+'), [[' ', '']]);
+        assert.deepEqual(element._parseQueryString('???+%3d+'), [[' = ', '']]);
+        assert.deepEqual(
+            element._parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
+            [['name', 'value']]);
+      });
+
+      test('multiple parameters', () => {
+        assert.deepEqual(
+            element._parseQueryString('a=b&c=d&e=f'),
+            [['a', 'b'], ['c', 'd'], ['e', 'f']]);
+        assert.deepEqual(
+            element._parseQueryString('&a=b&&&e=f&'),
+            [['a', 'b'], ['e', 'f']]);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
index 95a6167..f532e3f 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -32,8 +32,17 @@
         max-width: 15em;
       }
       .arrow {
+        color: rgba(0,0,0,.7);
         margin: 0 .5em;
       }
+      gr-dropdown-list {
+        --trigger-style: {
+          color: rgba(0,0,0,.7);
+          text-transform: none;
+          font-family: var(--font-family);
+        }
+        --trigger-hover-color: rgba(0,0,0,.6);
+      }
       @media screen and (max-width: 50em) {
         .filesWeblinks {
           display: none;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 5eef391..0b2985f 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -96,8 +96,9 @@
         dropdownContent.push({
           disabled: this._computeRightDisabled(patchNum, basePatchNum,
               _sortedRevisions),
-          triggerText: `Patchset ${patchNum}`,
-          text: `Patchset ${patchNum}` +
+          triggerText: `${patchNum === 'edit' ? '': 'Patchset '}` +
+              patchNum,
+          text: `${patchNum === 'edit' ? '': 'Patchset '}${patchNum}` +
               `${this._computePatchSetCommentsString(
                   this.comments, patchNum)}`,
           mobileText: this._computeMobileText(patchNum, this.comments,
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index 8ba3709..682956a 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -246,8 +246,8 @@
         },
         {
           disabled: false,
-          triggerText: 'Patchset edit',
-          text: 'Patchset edit',
+          triggerText: 'edit',
+          text: 'edit',
           mobileText: 'edit',
           bottomText: '',
           value: 'edit',
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
index bfd8e90..ca3cf62 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
@@ -26,6 +26,7 @@
 
         // NOTE: intended singleton.
         value: {
+          configured: false,
           loading: false,
           callbacks: [],
         },
@@ -60,12 +61,13 @@
     },
 
     _getHighlightLib() {
-      return window.hljs;
-    },
+      const lib = window.hljs;
+      if (lib && !this._state.configured) {
+        this._state.configured = true;
 
-    _configureHighlightLib() {
-      this._getHighlightLib().configure(
-          {classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+        lib.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+      }
+      return lib;
     },
 
     _getLibRoot() {
@@ -93,10 +95,8 @@
         }
 
         script.src = src;
-        script.onload = function() {
-          this._configureHighlightLib();
-          resolve();
-        }.bind(this);
+        script.onload = resolve;
+        script.onerror = reject;
         Polymer.dom(document.head).appendChild(script);
       });
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
index 6ddde46..6e88ed1 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
@@ -55,6 +55,7 @@
       loadStub.restore();
 
       // Because the element state is a singleton, clean it up.
+      element._state.configured = false;
       element._state.loading = false;
       element._state.callbacks = [];
     });
@@ -88,8 +89,13 @@
     });
 
     suite('preloaded', () => {
+      let hljsStub;
+
       setup(() => {
-        window.hljs = 'test-object';
+        hljsStub = {
+          configure: sinon.stub(),
+        };
+        window.hljs = hljsStub;
       });
 
       teardown(() => {
@@ -101,7 +107,14 @@
         element.get().then(firstCallHandler);
         flush(() => {
           assert.isTrue(firstCallHandler.called);
-          assert.isTrue(firstCallHandler.calledWith('test-object'));
+          assert.isTrue(firstCallHandler.calledWith(hljsStub));
+          done();
+        });
+      });
+
+      test('configures hljs', done => {
+        element.get().then(() => {
+          assert.isTrue(window.hljs.configure.calledOnce);
           done();
         });
       });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
new file mode 100644
index 0000000..bfbe11a
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
@@ -0,0 +1,48 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
+
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-edit-file-controls">
+  <template>
+    <style include="shared-styles">
+      :host {
+        align-items: center;
+        display: flex;
+        justify-content: flex-end;
+      }
+      #edit {
+        margin-right: .5em;
+        text-decoration: none;
+      }
+    </style>
+    <gr-button
+        id="edit"
+        link
+        on-tap="_handleEditTap">Edit</gr-button>
+    <!-- TODO(kaspern): implement more menu. -->
+    <gr-dropdown
+        id="more"
+        hidden
+        link>More</gr-dropdown>
+  </template>
+  <script src="gr-edit-file-controls.js"></script>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
new file mode 100644
index 0000000..1c87621
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
@@ -0,0 +1,34 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-edit-file-controls',
+
+    /**
+     * Fired when the edit button is pressed.
+     *
+     * @event edit-tap
+     */
+
+    properties: {
+      filePath: String,
+    },
+
+    _handleEditTap() {
+      this.fire('edit-tap', {path: this.filePath});
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
new file mode 100644
index 0000000..250e208
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
@@ -0,0 +1,56 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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-edit-file-controls</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-edit-file-controls.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-edit-file-controls></gr-edit-file-controls>
+  </template>
+</test-fixture>
+
+<script>
+suite('gr-edit-file-controls tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('edit tap emits event', () => {
+    const handler = sandbox.stub();
+    element.addEventListener('edit-tap', handler);
+    element.filePath = 'foo';
+
+    MockInteractions.tap(element.$.edit);
+    assert.isTrue(handler.called);
+    assert.equal(handler.lastCall.args[0].detail.path, 'foo');
+  });
+});
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
new file mode 100644
index 0000000..df2ac93
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
@@ -0,0 +1,102 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-fixed-panel/gr-fixed-panel.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+
+<dom-module id="gr-editor-view">
+  <template>
+    <style include="shared-styles">
+      :host {
+        background-color: var(--view-background-color);
+      }
+      gr-fixed-panel {
+        background-color: #fff;
+        border-bottom: 1px #eee solid;
+        z-index: 10;
+      }
+      header,
+      .subHeader {
+        align-items: center;
+        display: flex;
+        justify-content: space-between;
+        padding: .75em var(--default-horizontal-margin);
+      }
+      header gr-editable-label {
+        font-size: 1.2em;
+        font-weight: bold;
+      }
+      .textareaWrapper {
+        margin: var(--default-horizontal-margin);
+      }
+      .textareaWrapper textarea {
+        border: 1px solid #ddd;
+        border-radius: 3px;
+        box-sizing: border-box;
+        font-family: var(--monospace-font-family);
+        min-height: 60vh;
+        resize: none;
+        white-space: pre;
+        width: 100%;
+      }
+      .textareaWrapper textarea:focus {
+        outline: none;
+      }
+      .textareaWrapper .editButtons {
+        display: none;
+      }
+      .rightControls {
+        justify-content: flex-end
+      }
+    </style>
+    <gr-fixed-panel
+        class$="[[_computeContainerClass(_editLoaded)]]"
+        floating-disabled="[[_panelFloatingDisabled]]"
+        keep-on-scroll
+        ready-for-measure="[[!_loading]]">
+      <header>
+        <gr-editable-label
+            label-text="File path"
+            value="[[_path]]"
+            placeholder="File path..."
+            on-changed="_handlePathChanged"></gr-editable-label>
+        <span class="rightControls">
+          <gr-button
+              id="save"
+              disabled$="[[_saveDisabled]]"
+              primary
+              on-tap="_saveEdit">Save</gr-button>
+          <gr-button id="cancel" on-tap="_handleCancelTap">Cancel</gr-button>
+        </span>
+      </header>
+    </gr-fixed-panel>
+    <div class="textareaWrapper">
+      <textarea value="{{_newContent::input}}" id="file"></textarea>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-editor-view.js"></script>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
new file mode 100644
index 0000000..86594d3
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
@@ -0,0 +1,139 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-editor-view',
+
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      _change: Object,
+      _changeEditDetail: Object,
+      _changeNum: String,
+      _loggedIn: Boolean,
+      _path: String,
+      _content: String,
+      _newContent: String,
+      _saveDisabled: {
+        type: Boolean,
+        value: true,
+        computed: '_computeSaveDisabled(_content, _newContent)',
+      },
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PatchSetBehavior,
+      Gerrit.PathListBehavior,
+    ],
+
+    attached() {
+      this._getLoggedIn().then(loggedIn => { this._loggedIn = loggedIn; });
+    },
+
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _paramsChanged(value) {
+      if (value.view !== Gerrit.Nav.View.EDIT) { return; }
+
+      this._changeNum = value.changeNum;
+      this._path = value.path;
+
+      // NOTE: This may be called before attachment (e.g. while parentElement is
+      // null). Fire title-change in an async so that, if attachment to the DOM
+      // has been queued, the event can bubble up to the handler in gr-app.
+      this.async(() => {
+        const title = `Editing ${this.computeTruncatedPath(this._path)}`;
+        this.fire('title-change', {title});
+      });
+
+      const promises = [];
+
+      promises.push(this._getChangeDetail(this._changeNum));
+      promises.push(this._getFileContent(this._changeNum, this._path)
+          .then(fileContent => {
+            this._content = fileContent;
+            this._newContent = fileContent;
+          }));
+      return Promise.all(promises);
+    },
+
+    _getChangeDetail(changeNum) {
+      return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+        this._change = change;
+      });
+    },
+
+    _handlePathChanged(e) {
+      const path = e.detail;
+      if (path === this._path) { return Promise.resolve(); }
+      return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
+          this._path, path).then(res => {
+            if (!res.ok) { return; }
+            this._viewEditInChangeView();
+          });
+    },
+
+    _viewEditInChangeView() {
+      Gerrit.Nav.navigateToChange(this._change, this.EDIT_NAME);
+    },
+
+    _getFileContent(changeNum, path) {
+      return this.$.restAPI.getFileInChangeEdit(changeNum, path).then(res => {
+        if (!res.ok) {
+          if (res.status === 404) {
+            // No edits have been made yet.
+            return this.$.restAPI.getFileInChangeEdit(changeNum, path, true)
+                .then(res => res.text);
+          }
+          return '';
+        }
+        return res.text;
+      });
+    },
+
+    _saveEdit() {
+      return this.$.restAPI.saveChangeEdit(this._changeNum, this._path,
+          this._newContent).then(res => {
+            if (!res.ok) { return; }
+            this._viewEditInChangeView();
+          });
+    },
+
+    _computeSaveDisabled(content, newContent) {
+      return content === newContent;
+    },
+
+    _handleCancelTap() {
+      // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
+      this._viewEditInChangeView();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
new file mode 100644
index 0000000..e3e6474
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
@@ -0,0 +1,183 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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-editor-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-editor-view.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-editor-view></gr-editor-view>
+  </template>
+</test-fixture>
+
+<script>
+suite('gr-editor-view tests', () => {
+  let element;
+  let sandbox;
+  let savePathStub;
+  let saveFileStub;
+  let changeDetailStub;
+  let navigateStub;
+  const mockParams = {
+    changeNum: '42',
+    path: 'foo/bar.baz',
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+    });
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    savePathStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
+    saveFileStub = sandbox.stub(element.$.restAPI, 'saveChangeEdit');
+    changeDetailStub = sandbox.stub(element.$.restAPI, 'getDiffChangeDetail');
+    navigateStub = sandbox.stub(element, '_viewEditInChangeView');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  suite('_paramsChanged', () => {
+    test('incorrect view returns immediately', () => {
+      element._paramsChanged(
+          Object.assign({}, mockParams, {view: Gerrit.Nav.View.DIFF}));
+      assert.notOk(element._changeNum);
+    });
+
+    test('good params proceed', () => {
+      changeDetailStub.returns(Promise.resolve({}));
+      const fileStub = sandbox.stub(element, '_getFileContent')
+          .returns(Promise.resolve('text'));
+
+      const promises = element._paramsChanged(
+          Object.assign({}, mockParams, {view: Gerrit.Nav.View.EDIT}));
+
+      flushAsynchronousOperations();
+      assert.equal(element._changeNum, mockParams.changeNum);
+      assert.equal(element._path, mockParams.path);
+      assert.deepEqual(changeDetailStub.lastCall.args[0],
+          mockParams.changeNum);
+      assert.deepEqual(fileStub.lastCall.args,
+          [mockParams.changeNum, mockParams.path]);
+
+      return promises.then(() => {
+        assert.equal(element._content, 'text');
+        assert.equal(element._newContent, 'text');
+      });
+    });
+  });
+
+  test('edit file path', done => {
+    element._changeNum = mockParams.changeNum;
+    element._path = mockParams.path;
+    savePathStub.onFirstCall().returns(Promise.resolve({}));
+    savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
+
+    // Calling with the same path should not navigate.
+    element._handlePathChanged({detail: mockParams.path}).then(() => {
+      assert.isFalse(savePathStub.called);
+        // !ok response
+      element._handlePathChanged({detail: 'newPath'}).then(() => {
+        assert.isTrue(savePathStub.called);
+        assert.isFalse(navigateStub.called);
+        // ok response
+        element._handlePathChanged({detail: 'newPath'}).then(() => {
+          assert.isTrue(navigateStub.called);
+          done();
+        });
+      });
+    });
+  });
+
+  suite('edit file content', () => {
+    const originalText = 'file text';
+    const newText = 'file text changed';
+
+    setup(() => {
+      element._changeNum = mockParams.changeNum;
+      element._path = mockParams.path;
+      element._content = originalText;
+      element._newContent = originalText;
+      flushAsynchronousOperations();
+    });
+
+    test('initial load', () => {
+      assert.equal(element.$.file.value, originalText);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+    });
+
+    test('file modification and save, !ok response', done => {
+      const saveSpy = sandbox.spy(element, '_saveEdit');
+      saveFileStub.returns(Promise.resolve({ok: false}));
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.file.value, newText);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.save);
+      assert(saveSpy.called);
+      saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.deepEqual(saveFileStub.lastCall.args,
+            [mockParams.changeNum, mockParams.path, newText]);
+        assert.isFalse(navigateStub.called);
+        done();
+      });
+    });
+
+    test('file modification and save', done => {
+      const saveSpy = sandbox.spy(element, '_saveEdit');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.file.value, newText);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isTrue(navigateStub.called);
+        done();
+      });
+    });
+
+    test('file modification and cancel', () => {
+      const cancelSpy = sandbox.spy(element, '_handleCancelTap');
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.file.value, newText);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.cancel);
+      assert.isTrue(cancelSpy.called);
+      assert.isFalse(saveFileStub.called);
+      assert.isTrue(navigateStub.called);
+    });
+  });
+});
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 6f8a4a1..eac4131 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -46,6 +46,7 @@
 <link rel="import" href="./core/gr-reporting/gr-reporting.html">
 <link rel="import" href="./core/gr-router/gr-router.html">
 <link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
+<link rel="import" href="./edit/gr-editor-view/gr-editor-view.html">
 <link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
 <link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
@@ -151,11 +152,15 @@
             view-state="{{_viewState.changeView}}"
             back-page="[[_lastSearchPage]]"></gr-change-view>
       </template>
-      <template is="dom-if" if="[[_showDiffView]]" restamp="true">
-        <gr-diff-view
-            params="[[params]]"
-            change-view-state="{{_viewState.changeView}}"></gr-diff-view>
+      <template is="dom-if" if="[[_showEditorView]]" restamp="true">
+        <gr-editor-view
+            params="[[params]]"></gr-editor-view>
       </template>
+      <template is="dom-if" if="[[_showDiffView]]" restamp="true">
+          <gr-diff-view
+              params="[[params]]"
+              change-view-state="{{_viewState.changeView}}"></gr-diff-view>
+        </template>
       <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
         <gr-settings-view
             params="[[params]]"
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 4a38b85..8da8e16 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -56,6 +56,7 @@
       _showSettingsView: Boolean,
       _showAdminView: Boolean,
       _showCLAView: Boolean,
+      _showEditorView: Boolean,
       /** @type {?} */
       _viewState: Object,
       /** @type {?} */
@@ -139,6 +140,7 @@
       this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
       this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN);
       this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
+      this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
       if (this.params.justRegistered) {
         this.$.registration.open();
       }
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
index e750c07..18eeb87 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
@@ -20,6 +20,17 @@
   }
 
   /**
+   * Add a callback to arbitrary event.
+   * The callback may return false to prevent event bubbling.
+   * @param {string} event Event name
+   * @param {function(Event):boolean} callback
+   * @return {function()} Unsubscribe function.
+   */
+  GrEventHelper.prototype.on = function(event, callback) {
+    return this._listen(this.element, callback, {event});
+  };
+
+  /**
    * Add a callback to element click or touch.
    * The callback may return false to prevent event bubbling.
    * @param {function(Event):boolean} callback
@@ -43,6 +54,7 @@
 
   GrEventHelper.prototype._listen = function(container, callback, opt_options) {
     const capture = opt_options && opt_options.capture;
+    const event = opt_options && opt_options.event || 'tap';
     const handler = e => {
       if (e.path.indexOf(this.element) !== -1) {
         let mayContinue = true;
@@ -58,9 +70,9 @@
         }
       }
     };
-    container.addEventListener('tap', handler, capture);
+    container.addEventListener(event, handler, capture);
     const unsubscribe = () =>
-      container.removeEventListener('tap', handler, capture);
+      container.removeEventListener(event, handler, capture);
     this._unsubscribers.push(unsubscribe);
     return unsubscribe;
   };
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
index 9d42851..43c42a9 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
@@ -92,5 +92,12 @@
       flushAsynchronousOperations();
       assert.isFalse(tapStub.called);
     });
+
+    test('on()', done => {
+      instance.on('foo', () => {
+        done();
+      });
+      element.fire('foo');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-project-api/gr-plugin-project-command.html b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-plugin-project-command.html
new file mode 100644
index 0000000..87d11ad
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-plugin-project-command.html
@@ -0,0 +1,34 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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="../../admin/gr-project-command/gr-project-command.html">
+
+<dom-module id="gr-plugin-project-command">
+  <template>
+    <gr-project-command title="[[title]]">
+    </gr-project-command>
+  </template>
+  <script>
+    Polymer({
+      is: 'gr-plugin-project-command',
+      properties: {
+        title: String,
+        projectName: String,
+        config: Object,
+      },
+    });
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api.html b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api.html
new file mode 100644
index 0000000..0106533
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api.html
@@ -0,0 +1,23 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="gr-plugin-project-command.html">
+
+<dom-module id="gr-project-api">
+  <script src="gr-project-api.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api.js b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api.js
new file mode 100644
index 0000000..a173edd
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api.js
@@ -0,0 +1,60 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrProjectApi) { return; }
+
+  function GrProjectApi(plugin) {
+    this._hook = null;
+    this.plugin = plugin;
+  }
+
+  GrProjectApi.prototype._createHook = function(title) {
+    this._hook = this.plugin.hook('project-command').onAttached(element => {
+      const pluginCommand =
+            document.createElement('gr-plugin-project-command');
+      pluginCommand.title = title;
+      element.appendChild(pluginCommand);
+    });
+  };
+
+  GrProjectApi.prototype.createCommand = function(title, callback) {
+    if (this._hook) {
+      console.warn('Already set up.');
+      return this._hook;
+    }
+    this._createHook(title);
+    this._hook.onAttached(element => {
+      if (callback(element.projectName, element.config) === false) {
+        element.hidden = true;
+      }
+    });
+    return this;
+  };
+
+  GrProjectApi.prototype.onTap = function(callback) {
+    if (!this._hook) {
+      console.warn('Call createCommand first.');
+      return this;
+    }
+    this._hook.onAttached(element => {
+      this.plugin.eventHelper(element).on('command-tap', callback);
+    });
+    return this;
+  };
+
+  window.GrProjectApi = GrProjectApi;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api_test.html b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api_test.html
new file mode 100644
index 0000000..b0719f5
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-project-api/gr-project-api_test.html
@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT 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-project-api</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="../gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="gr-project-api.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-endpoint-decorator name="project-command">
+    </gr-endpoint-decorator>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-project-api tests', () => {
+    let sandbox;
+    let projectApi;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
+      projectApi = plugin.project();
+    });
+
+    teardown(() => {
+      projectApi = null;
+      sandbox.restore();
+    });
+
+    test('exists', () => {
+      assert.isOk(projectApi);
+    });
+
+    test('works', done => {
+      const attachedStub = sandbox.stub();
+      const tapStub = sandbox.stub();
+      projectApi
+          .createCommand('foo', attachedStub)
+          .onTap(tapStub);
+      const element = fixture('basic');
+      flush(() => {
+        assert.isTrue(attachedStub.called);
+        const pluginCommand = element.$$('gr-plugin-project-command');
+        assert.isOk(pluginCommand);
+        const command = pluginCommand.$$('gr-project-command');
+        assert.isOk(command);
+        assert.equal(command.title, 'foo');
+        assert.isFalse(tapStub.called);
+        MockInteractions.tap(command.$$('gr-button'));
+        assert.isTrue(tapStub.called);
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
index dfaa6eb..c665df4 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
@@ -44,7 +44,9 @@
           <template is="dom-repeat" items="[[_agreements]]">
             <tr>
               <td class="nameColumn">
-                <a href$="[[getUrlBase(item.url)]]">[[item.name]]</a>
+                <a href$="[[getUrlBase(item.url)]]" rel="external">
+                  [[item.name]]
+                </a>
               </td>
               <td class="descriptionColumn">[[item.description]]</td>
             </tr>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
index c0b17af..9be6497 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -116,7 +116,7 @@
         color: #aaa;
       }
     </style>
-    <paper-button raised="[[!link]]" disabled="[[disabled]]">
+    <paper-button raised="[[!link]]" disabled="[[disabled]]" tabindex="-1">
       <content></content>
       <i class="downArrow"></i>
     </paper-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
index 27c0355..575353d 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
@@ -45,20 +45,23 @@
       footer {
         padding: .5em .65em;
       }
+      gr-button {
+        margin-left: 1em;
+      }
       footer {
         display: flex;
         flex-shrink: 0;
-        justify-content: space-between;
+        justify-content: flex-end;
       }
     </style>
     <div class="container">
       <header><content select=".header"></content></header>
       <main><content select=".main"></content></main>
       <footer>
-        <gr-button primary on-tap="_handleConfirmTap" disabled="[[disabled]]">
+        <gr-button link on-tap="_handleCancelTap">Cancel</gr-button>
+        <gr-button link primary on-tap="_handleConfirmTap" disabled="[[disabled]]">
           [[confirmLabel]]
         </gr-button>
-        <gr-button on-tap="_handleCancelTap">Cancel</gr-button>
       </footer>
     </div>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
index 91c2def..0916a89 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
@@ -72,10 +72,6 @@
           background-color: #f2f2f2;
         }
       }
-      gr-button {
-        --gr-button-arrow-color: var(--color-link);
-        --gr-button-arrow-hover-color: var(--color-link-hover);
-      }
       paper-item:not(:last-of-type) {
         border-bottom: 1px solid #ddd;
       }
@@ -95,6 +91,12 @@
         flex-direction: row;
         width: 100%;
       }
+       gr-button {
+        --gr-button: {
+          @apply --trigger-style;
+        }
+        --gr-button-hover-color: var(--trigger-hover-color);
+      }
       gr-date-formatter {
         color: rgba(0,0,0,.54);
         margin-left: 2em;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
index 6eb7c8d..ef78f3a 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
@@ -92,8 +92,8 @@
                 label="[[labelText]]"
                 value="{{_inputText}}"></paper-input>
             <div class="buttons">
-              <gr-button id="cancelBtn" on-tap="_cancel">cancel</gr-button>
-              <gr-button id="saveBtn" on-tap="_save">save</gr-button>
+              <gr-button link id="cancelBtn" on-tap="_cancel">cancel</gr-button>
+              <gr-button link id="saveBtn" on-tap="_save">save</gr-button>
             </div>
           </div>
         </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index 4133600..c5b0441 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -21,6 +21,7 @@
 <link rel="import" href="../../plugins/gr-dom-hooks/gr-dom-hooks.html">
 <link rel="import" href="../../plugins/gr-event-helper/gr-event-helper.html">
 <link rel="import" href="../../plugins/gr-popup-interface/gr-popup-interface.html">
+<link rel="import" href="../../plugins/gr-project-api/gr-project-api.html">
 <link rel="import" href="../../plugins/gr-theme-api/gr-theme-api.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 467e012..4e6b8cf 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -198,6 +198,10 @@
     return new GrThemeApi(this);
   };
 
+  Plugin.prototype.project = function() {
+    return new GrProjectApi(this);
+  };
+
   Plugin.prototype.attributeHelper = function(element) {
     return new GrAttributeHelper(element);
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 5153fb0..c206b20 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -765,6 +765,11 @@
         // Response may be an array of changes OR an array of arrays of
         // changes.
         if (opt_query instanceof Array) {
+          // Normalize the response to look like a multi-query response
+          // when there is only one query.
+          if (opt_query.length === 1) {
+            response = [response];
+          }
           for (const arr of response) {
             iterateOverChanges(arr);
           }
@@ -1249,9 +1254,25 @@
           .then(response => this.getResponseObject(response));
     },
 
-    getFileInChangeEdit(changeNum, path) {
+    /**
+     * Gets a file in a change edit.
+     * @param {number|string} changeNum
+     * @param {string} path
+     * @param {boolean=} opt_base If specified, file contents come from change
+     *     edit's base patchset.
+     */
+    getFileInChangeEdit(changeNum, path, opt_base) {
       const e = '/edit/' + encodeURIComponent(path);
-      return this.getChangeURLAndSend(changeNum, 'GET', null, e);
+      let payload = null;
+      if (opt_base) { payload = {base: true}; }
+      return this.getChangeURLAndSend(changeNum, 'GET', null, e, payload)
+          .then(res => {
+            if (!res.ok) { return res; }
+            return res.text().then(text => {
+              res.text = atob(text);
+              return res;
+            });
+          });
     },
 
     rebaseChangeEdit(changeNum) {
@@ -1279,7 +1300,8 @@
 
     saveChangeEdit(changeNum, path, contents) {
       const e = '/edit/' + encodeURIComponent(path);
-      return this.getChangeURLAndSend(changeNum, 'PUT', null, e, contents);
+      return this.getChangeURLAndSend(changeNum, 'PUT', null, e, contents, null,
+          null, 'text/plain');
     },
 
     // Deprecated, prefer to use putChangeCommitMessage instead.
@@ -1845,7 +1867,7 @@
      * @param {?string} endpoint gets passed as null.
      * @param {?Object|number|string=} opt_payload gets passed as null, string,
      *    Object, or number.
-     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?function(?Response, string=)=} opt_errFn
      * @param {?=} opt_ctx
      * @param {?=} opt_contentType
      * @return {!Promise<!Object>}
diff --git a/polygerrit-ui/app/samples/project-command.html b/polygerrit-ui/app/samples/project-command.html
new file mode 100644
index 0000000..8131a02
--- /dev/null
+++ b/polygerrit-ui/app/samples/project-command.html
@@ -0,0 +1,42 @@
+<dom-module id="sample-project-command">
+  <script>
+    Gerrit.install(plugin => {
+      // High-level API
+      plugin.project()
+          .createCommand('Bork', (projectName, projectConfig) => {
+            if (projectName !== 'All-Projects') {
+              return false;
+            }
+          }).onTap(() => {
+            alert('Bork, bork!');
+          });
+
+      // Low-level API
+      plugin.registerCustomComponent(
+          'project-command', 'project-command-low');
+    });
+  </script>
+</dom-module>
+
+<!-- Low-level custom component for project command. -->
+<dom-module id="project-command-low">
+  <template>
+    <gr-project-command
+        title="Low-level bork"
+        on-command-tap="_handleCommandTap">
+    </gr-project-command>
+  </template>
+  <script>
+    Polymer({
+      is: 'project-command-low',
+      attached() {
+        console.log(this.projectName);
+        console.log(this.config);
+        this.hidden = this.projectName !== 'All-Projects';
+      },
+      _handleCommandTap() {
+        alert('(softly) bork, bork.');
+      },
+    });
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 7080eb7..b0310c6 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -44,6 +44,7 @@
     'admin/gr-permission/gr-permission_test.html',
     'admin/gr-plugin-list/gr-plugin-list_test.html',
     'admin/gr-project-access/gr-project-access_test.html',
+    'admin/gr-project-command/gr-project-command_test.html',
     'admin/gr-project-commands/gr-project-commands_test.html',
     'admin/gr-project-detail-list/gr-project-detail-list_test.html',
     'admin/gr-project-list/gr-project-list_test.html',
@@ -102,10 +103,13 @@
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
     'diff/gr-syntax-layer/gr-syntax-layer_test.html',
     'diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html',
+    'edit/gr-edit-file-controls/gr-edit-file-controls_test.html',
+    'edit/gr-editor-view/gr-editor-view_test.html',
     'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
     'plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html',
     'plugins/gr-event-helper/gr-event-helper_test.html',
     'plugins/gr-external-style/gr-external-style_test.html',
+    'plugins/gr-project-api/gr-project-api_test.html',
     'plugins/gr-plugin-host/gr-plugin-host_test.html',
     'plugins/gr-popup-interface/gr-plugin-popup_test.html',
     'plugins/gr-popup-interface/gr-popup-interface_test.html',
diff --git a/version.bzl b/version.bzl
index 3be5283..62d841f 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "2.15-rc0"
+GERRIT_VERSION = "2.16-SNAPSHOT"