Merge "Fix error when try assign on getter with existing property"
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
index ca5ff62..888b7dc 100644
--- a/Documentation/dev-design-docs.txt
+++ b/Documentation/dev-design-docs.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Design Docs
 
 For the link:dev-contributing.html#design-driven-contribution-process[
@@ -98,6 +99,13 @@
 Everyone in the link:dev-roles.html[Gerrit community] is welcome to
 take part in the design review and comment on the design.
 
+Positive `Code-Review` votes on changes that add/modify design docs are
+sticky. This means any `Code-Review+1` and `Code-Review+2` vote is
+preserved when a new patch set is uploaded. If a new patch set makes
+significant changes, the uploader of the new patch set must start a new
+review round by removing all positive `Code-Review` votes from the
+change manually.
+
 Ideas for alternative solutions should be uploaded as a change that
 describes the solution (see link:#collaboration[above]).
 
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 6a66ba3..2ebe457 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -45,13 +45,15 @@
 import java.util.regex.Pattern;
 
 /** Helper for generating parts of {@code index.html}. */
+@UsedAt(Project.GOOGLE)
 public class IndexHtmlUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-  static final String changeCanonicalUrl = ".*/c/(?<project>.+)/\\+/(?<changeNum>\\d+)";
-  static final String basePatchNumUrlPart = "(/(-?\\d+|edit)(\\.\\.(\\d+|edit))?)";
-  static final Pattern changeUrlPattern =
+  public static final String changeCanonicalUrl = ".*/c/(?<project>.+)/\\+/(?<changeNum>\\d+)";
+  public static final String basePatchNumUrlPart = "(/(-?\\d+|edit)(\\.\\.(\\d+|edit))?)";
+  public static final Pattern changeUrlPattern =
       Pattern.compile(changeCanonicalUrl + basePatchNumUrlPart + "?" + "/?$");
-  static final Pattern diffUrlPattern =
+
+  public static final Pattern diffUrlPattern =
       Pattern.compile(changeCanonicalUrl + basePatchNumUrlPart + "(/(.+))" + "/?$");
 
   public static String getDefaultChangeDetailHex() {
@@ -80,6 +82,18 @@
     return ListOption.toHex(options);
   }
 
+  public static String computeChangeRequestsPath(String requestedURL, Pattern pattern) {
+    Matcher matcher = pattern.matcher(requestedURL);
+    if (matcher.matches()) {
+      Integer changeId = Ints.tryParse(matcher.group("changeNum"));
+      if (changeId != null) {
+        return "changes/" + Url.encode(matcher.group("project")) + "~" + changeId;
+      }
+    }
+
+    return null;
+  }
+
   /**
    * Returns both static and dynamic parameters of {@code index.html}. The result is to be used when
    * rendering the soy template.
@@ -107,7 +121,6 @@
   }
 
   /** Returns dynamic parameters of {@code index.html}. */
-  @UsedAt(Project.GOOGLE)
   public static Map<String, Map<String, SanitizedContent>> dynamicTemplateData(GerritApi gerritApi)
       throws RestApiException {
     Gson gson = OutputFormat.JSON_COMPACT.newGson();
@@ -201,18 +214,6 @@
     return data.build();
   }
 
-  static String computeChangeRequestsPath(String requestedURL, Pattern pattern) {
-    Matcher matcher = pattern.matcher(requestedURL);
-    if (matcher.matches()) {
-      Integer changeId = Ints.tryParse(matcher.group("changeNum"));
-      if (changeId != null) {
-        return "changes/" + Url.encode(matcher.group("project")) + "~" + changeId;
-      }
-    }
-
-    return null;
-  }
-
   private static String computeCanonicalPath(@Nullable String canonicalURL)
       throws URISyntaxException {
     if (Strings.isNullOrEmpty(canonicalURL)) {
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 1fd0f82..4af7872 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -1716,7 +1716,7 @@
         req.getMethod(), req.getRequestURI(), getParameterNames(req));
     logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
     logger.atFinest().log(
-        "Groups: %s", globals.currentUser.get().getEffectiveGroups().getKnownGroups());
+        "Groups: %s", lazy(() -> globals.currentUser.get().getEffectiveGroups().getKnownGroups()));
   }
 
   private boolean isDelete(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index a4c9f2a..cb9f10d 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.Account;
@@ -54,6 +55,8 @@
  */
 @Singleton
 public class ApprovalInference {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
   private final LabelNormalizer labelNormalizer;
@@ -95,29 +98,175 @@
     checkArgument(n != psId.get());
     LabelType type = project.getLabelTypes().byLabel(psa.labelId());
     if (type == null) {
+      logger.atFine().log(
+          "approval %d on label %s of patch set %d of change %d cannot be copied"
+              + " to patch set %d because the label no longer exists on project %s",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          project.getName());
       return false;
-    } else if ((type.isCopyMinScore() && type.isMaxNegative(psa))
-        || (type.isCopyMaxScore() && type.isMaxPositive(psa))) {
+    } else if (type.isCopyMinScore() && type.isMaxNegative(psa)) {
+      logger.atFine().log(
+          "veto approval %s on label %s of patch set %d of change %d can be copied"
+              + " to patch set %d because the label has set copyMinScore = true on project %s",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          project.getName());
+      return true;
+    } else if (type.isCopyMaxScore() && type.isMaxPositive(psa)) {
+      logger.atFine().log(
+          "max approval %s on label %s of patch set %d of change %d can be copied"
+              + " to patch set %d because the label has set copyMaxScore = true on project %s",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          project.getName());
       return true;
     } else if (type.isCopyAnyScore()) {
+      logger.atFine().log(
+          "approval %d on label %s of patch set %d of change %d can be copied"
+              + " to patch set %d because the label has set copyAnyScore = true on project %s",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          project.getName());
       return true;
     } else if (type.getCopyValues().contains(psa.value())) {
+      logger.atFine().log(
+          "approval %d on label %s of patch set %d of change %d can be copied"
+              + " to patch set %d because the label has set copyValue = %d on project %s",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          psa.value(),
+          project.getName());
       return true;
     }
     switch (kind) {
       case MERGE_FIRST_PARENT_UPDATE:
-        return type.isCopyAllScoresOnMergeFirstParentUpdate();
+        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        return false;
       case NO_CODE_CHANGE:
-        return type.isCopyAllScoresIfNoCodeChange();
+        if (type.isCopyAllScoresIfNoCodeChange()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresIfNoCodeChange = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        return false;
       case TRIVIAL_REBASE:
-        return type.isCopyAllScoresOnTrivialRebase();
+        if (type.isCopyAllScoresOnTrivialRebase()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresOnTrivialRebase = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        return false;
       case NO_CHANGE:
-        return type.isCopyAllScoresIfNoChange()
-            || type.isCopyAllScoresOnTrivialRebase()
-            || type.isCopyAllScoresOnMergeFirstParentUpdate()
-            || type.isCopyAllScoresIfNoCodeChange();
+        if (type.isCopyAllScoresIfNoChange()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresIfNoCodeChange = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        if (type.isCopyAllScoresOnTrivialRebase()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresOnTrivialRebase = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        if (type.isCopyAllScoresIfNoCodeChange()) {
+          logger.atFine().log(
+              "approval %d on label %s of patch set %d of change %d can be copied"
+                  + " to patch set %d because change kind is %s and the label has set"
+                  + " copyAllScoresIfNoCodeChange = true on project %s",
+              psa.value(),
+              psa.label(),
+              n,
+              psa.key().patchSetId().changeId().get(),
+              psId.get(),
+              kind,
+              project.getName());
+          return true;
+        }
+        return false;
       case REWORK:
       default:
+        logger.atFine().log(
+            "approval %d on label %s of patch set %d of change %d cannot be copied"
+                + " to patch set %d because change kind is %s",
+            psa.value(), psa.label(), n, psa.key().patchSetId().changeId().get(), psId.get(), kind);
         return false;
     }
   }
@@ -179,6 +328,9 @@
             repoConfig,
             priorPatchSet.getValue().commitId(),
             ps.commitId());
+    logger.atFine().log(
+        "change kind for patch set %d of change %d against prior patch set %s is %s",
+        ps.id().get(), ps.id().changeId().get(), priorPatchSet.getValue().id().changeId(), kind);
     for (PatchSetApproval psa : priorApprovals) {
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 032f08e..b5035d8 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static java.util.Objects.requireNonNull;
 
@@ -38,6 +39,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
@@ -96,7 +98,8 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-public class RevertSubmission implements RestModifyView<ChangeResource, RevertInput> {
+public class RevertSubmission
+    implements RestModifyView<ChangeResource, RevertInput>, UiAction<ChangeResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<InternalChangeQuery> queryProvider;
@@ -527,6 +530,40 @@
             potentialCommitToReturn.getName(), changeNotes.getChange().getChangeId()));
   }
 
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    boolean projectStatePermitsWrite = false;
+    try {
+      projectStatePermitsWrite = projectCache.checkedGet(rsrc.getProject()).statePermitsWrite();
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(
+          "Failed to check if project state permits write: %s", rsrc.getProject());
+    }
+    return new UiAction.Description()
+        .setLabel("Revert submission")
+        .setTitle(
+            "Revert this change and all changes that have been submitted together with this change")
+        .setVisible(
+            and(
+                change.isMerged()
+                    && change.getSubmissionId() != null
+                    && isChangePartOfSubmission(change.getSubmissionId())
+                    && projectStatePermitsWrite,
+                permissionBackend
+                    .user(rsrc.getUser())
+                    .ref(change.getDest())
+                    .testCond(CREATE_CHANGE)));
+  }
+
+  /**
+   * @param submissionId the submission id of the change.
+   * @return True if the submission has more than one change, false otherwise.
+   */
+  private Boolean isChangePartOfSubmission(String submissionId) {
+    return (queryProvider.get().setLimit(2).bySubmissionId(submissionId).size() > 1);
+  }
+
   private class CreateCherryPickOp implements BatchUpdateOp {
     private final ObjectId revCommitId;
     private final String topic;
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index e13cab1..2db625b 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -482,7 +482,8 @@
                     String traceId = "retry-on-failure-" + new RequestId();
                     traceContext.addTag(RequestId.Type.TRACE_ID, traceId).forceLogging();
                     logger.atFine().withCause(t).log(
-                        "AutoRetry: %s failed, retry with tracing enabled", actionName);
+                        "AutoRetry: %s failed, retry with tracing enabled (cause = %s)",
+                        actionName, cause);
                     opts.onAutoTrace().ifPresent(c -> c.accept(traceId));
                     metrics.autoRetryCount.increment(actionType, actionName, cause);
                     return true;
@@ -492,7 +493,7 @@
                   // enabled and it failed again. Log the failure so that admin can see if it
                   // differs from the failure that triggered the retry.
                   logger.atFine().withCause(t).log(
-                      "AutoRetry: auto-retry of %s has failed", actionName);
+                      "AutoRetry: auto-retry of %s has failed (cause = %s)", actionName, cause);
                   metrics.failuresOnAutoRetryCount.increment(actionType, actionName, cause);
                   return false;
                 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index 28b8cae..911a04d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -78,6 +78,22 @@
     gApi.changes().id(changeId).current().submit();
     Map<String, ActionInfo> actions = getChangeActions(changeId);
     assertThat(actions).containsKey("revert");
+    assertThat(actions).doesNotContainKey("revert_submission");
+  }
+
+  @Test
+  public void changeActionTwoMergedChangesHaveReverts() throws Exception {
+    String changeId1 = createChangeWithTopic().getChangeId();
+    String changeId2 = createChangeWithTopic().getChangeId();
+    gApi.changes().id(changeId1).current().review(ReviewInput.approve());
+    gApi.changes().id(changeId2).current().review(ReviewInput.approve());
+    gApi.changes().id(changeId2).current().submit();
+    Map<String, ActionInfo> actions1 = getChangeActions(changeId1);
+    assertThat(actions1).containsKey("revert");
+    assertThat(actions1).containsKey("revert_submission");
+    Map<String, ActionInfo> actions2 = getChangeActions(changeId2);
+    assertThat(actions2).containsKey("revert");
+    assertThat(actions2).containsKey("revert_submission");
   }
 
   @Test
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index 495f8ab..97c792a 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -269,17 +269,17 @@
   _describe(Shortcut.NEXT_UNREVIEWED_FILE, ShortcutSection.DIFFS,
       'Mark file as reviewed and go to next unreviewed file');
 
-  _describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Select next file');
+  _describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file');
   _describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
-      'Select previous file');
+      'Go to previous file');
   _describe(Shortcut.NEXT_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
-      'Select next file that has comments');
+      'Go to next file that has comments');
   _describe(Shortcut.PREV_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
-      'Select previous file that has comments');
+      'Go to previous file that has comments');
   _describe(Shortcut.OPEN_FIRST_FILE, ShortcutSection.NAVIGATION,
-      'Show first file');
+      'Go to first file');
   _describe(Shortcut.OPEN_LAST_FILE, ShortcutSection.NAVIGATION,
-      'Show last file');
+      'Go to last file');
   _describe(Shortcut.UP_TO_DASHBOARD, ShortcutSection.NAVIGATION,
       'Up to dashboard');
   _describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
@@ -350,6 +350,17 @@
       return this.listeners.delete(listener);
     }
 
+    getDescription(section, shortcutName) {
+      const binding =
+          _help.get(section).find(binding => binding.shortcut == shortcutName);
+      return binding ? binding.text : '';
+    }
+
+    getShortcut(shortcutName) {
+      const binding = this.bindings.get(shortcutName);
+      return binding ? this.describeBinding(binding) : '';
+    }
+
     activeShortcutsBySection() {
       const activeShortcuts = new Set();
       this.activeHosts.forEach(shortcuts => {
@@ -478,6 +489,8 @@
       GO_KEY: GO_KEY,
       // eslint-disable-next-line object-shorthand
       Shortcut: Shortcut,
+      // eslint-disable-next-line object-shorthand
+      ShortcutSection: ShortcutSection,
 
       properties: {
         _shortcut_go_key_last_pressed: {
@@ -511,6 +524,11 @@
         for (let i = 0; e.path && i < e.path.length; i++) {
           if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
         }
+
+        this.fire('shortcut-triggered', {
+          event: e,
+          goKey: this._inGoKeyMode(),
+        });
         return false;
       },
 
@@ -528,6 +546,12 @@
         shortcutManager.bindShortcut(shortcut, ...bindings);
       },
 
+      createTitle(shortcutName, section) {
+        const desc = shortcutManager.getDescription(section, shortcutName);
+        const shortcut = shortcutManager.getShortcut(shortcutName);
+        return (desc && shortcut) ? `${desc} (shortcut: ${shortcut})` : '';
+      },
+
       _addOwnKeyBindings(shortcut, handler) {
         const bindings = shortcutManager.getBindingsForShortcut(shortcut);
         if (!bindings) {
@@ -591,10 +615,14 @@
         this._shortcut_go_key_last_pressed = null;
       },
 
+      _inGoKeyMode() {
+        return this._shortcut_go_key_last_pressed &&
+            (Date.now() - this._shortcut_go_key_last_pressed <=
+                GO_KEY_TIMEOUT_MS);
+      },
+
       _handleGoAction(e) {
-        if (!this._shortcut_go_key_last_pressed ||
-            (Date.now() - this._shortcut_go_key_last_pressed >
-                GO_KEY_TIMEOUT_MS) ||
+        if (!this._inGoKeyMode() ||
             !this._shortcut_go_table.has(e.detail.key) ||
             this.shouldSuppressKeyboardShortcut(e)) {
           return;
@@ -632,6 +660,11 @@
      */
     Gerrit.KeyboardShortcutMixin = base =>
       class extends base {
+        get Shortcut() { return undefined; }
+
+        get ShortcutSection() { return undefined; }
+
+        createTitle(shortcutName, section) {}
       };
   }
 })(window);
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
index ba143ec..f1bbcb0 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
@@ -189,7 +189,7 @@
               mapToObject(mgr.activeShortcutsBySection()),
               {
                 [NAVIGATION]: [
-                  {shortcut: NEXT_FILE, text: 'Select next file'},
+                  {shortcut: NEXT_FILE, text: 'Go to next file'},
                 ],
               });
 
@@ -207,7 +207,7 @@
                   {shortcut: NEXT_LINE, text: 'Go to next line'},
                 ],
                 [NAVIGATION]: [
-                  {shortcut: NEXT_FILE, text: 'Select next file'},
+                  {shortcut: NEXT_FILE, text: 'Go to next file'},
                 ],
               });
 
@@ -233,7 +233,7 @@
                   },
                 ],
                 [NAVIGATION]: [
-                  {shortcut: NEXT_FILE, text: 'Select next file'},
+                  {shortcut: NEXT_FILE, text: 'Go to next file'},
                 ],
               });
         });
@@ -286,7 +286,7 @@
                   {binding: [['g', 'o']], text: 'Go to Opened Changes'},
                 ],
                 [NAVIGATION]: [
-                  {binding: [[']']], text: 'Select next file'},
+                  {binding: [[']']], text: 'Go to next file'},
                 ],
               });
         });
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index 11e2217..c62aba2 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -441,6 +441,8 @@
                 <gr-button
                     id="replyBtn"
                     class="reply"
+                    title="[[createTitle(Shortcut.OPEN_REPLY_DIALOG,
+                      ShortcutSection.ACTIONS)]]"
                     hidden$="[[!_loggedIn]]"
                     primary
                     disabled="[[_replyDisabled]]"
@@ -637,6 +639,7 @@
             threads="[[_commentThreads]]"
             change="[[_change]]"
             change-num="[[_changeNum]]"
+            comment-tab="[[_currentView]]"
             logged-in="[[_loggedIn]]"
             only-show-robot-comments-with-human-reply
             on-thread-list-modified="_handleReloadDiffComments"></gr-thread-list>
@@ -654,6 +657,7 @@
             change="[[_change]]"
             change-num="[[_changeNum]]"
             logged-in="[[_loggedIn]]"
+            comment-tab="[[_currentView]]"
             hide-toggle-buttons
             on-thread-list-modified="_handleReloadDiffComments"></gr-thread-list>
       </template>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
index feb3d2f..8932af7 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
@@ -21,10 +21,12 @@
 
   /**
    * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
    * @extends Polymer.Element
    */
   class GrConfirmMoveDialog extends Polymer.mixinBehaviors( [
     Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
   ], Polymer.GestureEventListeners(
       Polymer.LegacyElementMixin(
           Polymer.Element))) {
@@ -55,6 +57,12 @@
       };
     }
 
+    get keyBindings() {
+      return {
+        'ctrl+enter meta+enter': '_handleConfirmTap',
+      };
+    }
+
     _handleConfirmTap(e) {
       e.preventDefault();
       e.stopPropagation();
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 9146125..6fb6746 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
@@ -223,6 +223,8 @@
         <span class="downloadContainer desktop">
           <gr-button link
               class="download"
+              title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
+                ShortcutSection.ACTIONS)]]"
               on-click="_handleDownloadTap">Download</gr-button>
         </span>
         <span class$="includedInContainer [[_hideIncludedIn(change)]] desktop">
@@ -235,6 +237,8 @@
           <gr-button
               id="expandBtn"
               link
+              title="[[createTitle(Shortcut.EXPAND_ALL_DIFF_CONTEXT,
+                ShortcutSection.DIFFS)]]"
               on-click="_expandAllDiffs">Expand All</gr-button>
           <gr-button
               id="collapseBtn"
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
index 5828006..eb7c1f9 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
@@ -24,11 +24,13 @@
   /**
    * @appliesMixin Gerrit.FireMixin
    * @appliesMixin Gerrit.PatchSetMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
    * @extends Polymer.Element
    */
   class GrFileListHeader extends Polymer.mixinBehaviors( [
     Gerrit.FireBehavior,
     Gerrit.PatchSetBehavior,
+    Gerrit.KeyboardShortcutBehavior,
   ], Polymer.GestureEventListeners(
       Polymer.LegacyElementMixin(
           Polymer.Element))) {
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
index 29b83a3..8a8a9d5 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
@@ -23,7 +23,7 @@
 <dom-module id="gr-label-scores">
   <template>
     <style include="shared-styles">
-      :host {
+      .scoresTable {
         display: table;
         width: 100%;
       }
@@ -42,15 +42,17 @@
         display: var(--label-no-access-display, table-row);
       }
     </style>
-    <template is="dom-repeat" items="[[_labels]]" as="label">
-      <gr-label-score-row
-          class$="[[_computeLabelAccessClass(label.name, permittedLabels)]]"
-          label="[[label]]"
-          name="[[label.name]]"
-          labels="[[change.labels]]"
-          permitted-labels="[[permittedLabels]]"
-          label-values="[[_labelValues]]"></gr-label-score-row>
-    </template>
+    <div class="scoresTable">
+      <template is="dom-repeat" items="[[_labels]]" as="label">
+        <gr-label-score-row
+            class$="[[_computeLabelAccessClass(label.name, permittedLabels)]]"
+            label="[[label]]"
+            name="[[label.name]]"
+            labels="[[change.labels]]"
+            permitted-labels="[[permittedLabels]]"
+            label-values="[[_labelValues]]"></gr-label-score-row>
+      </template>
+    </div>
     <div class="mergedMessage"
         hidden$="[[!_changeIsMerged(change.status)]]">
       Because this change has been merged, votes may not be decreased.
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 8d76510..1e57693 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
@@ -17,6 +17,7 @@
 
 <link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../gr-message/gr-message.html">
@@ -84,6 +85,7 @@
         <gr-button
             id="collapse-messages"
             link
+            title="[[_expandCollapseTitle]]"
             on-click="_handleExpandCollapseTap">
           [[_computeExpandCollapseMessage(_expanded)]]
         </gr-button>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index 20680b5..c637870 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -25,10 +25,15 @@
     SHOW_MORE: 'show-more-messages',
   };
 
-  /** @extends Polymer.Element */
-  class GrMessagesList extends Polymer.GestureEventListeners(
+  /**
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @extends Polymer.Element
+   */
+  class GrMessagesList extends Polymer.mixinBehaviors( [
+    Gerrit.KeyboardShortcutBehavior,
+  ], Polymer.GestureEventListeners(
       Polymer.LegacyElementMixin(
-          Polymer.Element)) {
+          Polymer.Element))) {
     static get is() { return 'gr-messages-list'; }
 
     static get properties() {
@@ -55,6 +60,11 @@
           value: false,
           observer: '_expandedChanged',
         },
+
+        _expandCollapseTitle: {
+          type: String,
+        },
+
         _hideAutomated: {
           type: Boolean,
           value: false,
@@ -174,6 +184,14 @@
           this.notifyPath(`_visibleMessages.${i}.expanded`);
         }
       }
+
+      if (this._expanded) {
+        this._expandCollapseTitle = this.createTitle(
+            this.Shortcut.COLLAPSE_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
+      } else {
+        this._expandCollapseTitle = this.createTitle(
+            this.Shortcut.EXPAND_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
+      }
     }
 
     _highlightEl(el) {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
index a096aec..eacc0d0 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
@@ -76,7 +76,7 @@
     </template>
     <div id="threads">
       <template is="dom-if" if="[[!threads.length]]">
-        There are no inline comment threads on any diff for this change.
+        [[_computeNoThreadsMessage(commentTab)]]
       </template>
       <template
           is="dom-repeat"
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
index e449fad..d8c7b61 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
@@ -23,6 +23,17 @@
    * @event thread-list-modified
    * @extends Polymer.Element
    */
+  const NO_THREADS_MESSAGE = 'There are no inline comment threads on any diff '
+    + 'for this change.';
+  const NO_ROBOT_COMMENTS_THREADS_MESSAGE = 'There are no findings for this ' +
+    'patchset.';
+
+  const CommentTabs = {
+    CHANGE_LOG: 0,
+    COMMENT_THREADS: 1,
+    ROBOT_COMMENTS: 2,
+  };
+
   class GrThreadList extends Polymer.GestureEventListeners(
       Polymer.LegacyElementMixin(
           Polymer.Element)) {
@@ -62,6 +73,7 @@
           type: Boolean,
           value: false,
         },
+        commentTab: Number,
       };
     }
 
@@ -71,6 +83,13 @@
       return loggedIn ? 'show' : '';
     }
 
+    _computeNoThreadsMessage(commentTab) {
+      if (commentTab === CommentTabs.ROBOT_COMMENTS) {
+        return NO_ROBOT_COMMENTS_THREADS_MESSAGE;
+      }
+      return NO_THREADS_MESSAGE;
+    }
+
     /**
      * Order as follows:
      *  - Unresolved threads with drafts (reverse chronological)
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
index 09b928e..ffd7f896 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
@@ -34,6 +34,9 @@
           max-width: 50em;
         }
       }
+      .signInLink {
+        text-decoration: none;
+      }
     </style>
     <gr-dialog
         id="dialog"
@@ -43,6 +46,14 @@
         confirm-on-enter>
       <div class="header" slot="header">An error occurred</div>
       <div class="main" slot="main">[[text]]</div>
+      <gr-button
+          id="signIn"
+          class$="signInLink"
+          hidden$="[[!showSignInButton]]"
+          link
+          slot="footer">
+        <a href$="[[loginUrl]]" class="signInLink">Sign in</a>
+      </gr-button>
     </gr-dialog>
   </template>
   <script src="gr-error-dialog.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
index db70d57..63339c9 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
@@ -31,6 +31,20 @@
     static get properties() {
       return {
         text: String,
+        /**
+         * loginUrl to open on "sign in" button click
+         */
+        loginUrl: {
+          type: String,
+          value: '/login',
+        },
+        /**
+         * Show/hide "Sign In" button in dialog
+         */
+        showSignInButton: {
+          type: Boolean,
+          value: false,
+        },
       };
     }
 
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
index 03d8d6a..104d5b0 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -34,7 +34,9 @@
           id="errorDialog"
           on-dismiss="_handleDismissErrorDialog"
           confirm-label="Dismiss"
-          confirm-on-enter></gr-error-dialog>
+          confirm-on-enter
+          login-url="[[loginUrl]]"
+      ></gr-error-dialog>
     </gr-overlay>
     <gr-overlay
       id="noInteractionOverlay"
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 5a7e58d..b828774 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -62,6 +62,11 @@
           type: Number,
           value() { return Date.now(); },
         },
+
+        loginUrl: {
+          type: String,
+          value: '/login',
+        },
       };
     }
 
@@ -139,23 +144,60 @@
           this._authService.clearCache();
           this.$.restAPI.getLoggedIn();
         } else if (!this._shouldSuppressError(errorText)) {
-          this._showErrorDialog(this._constructServerErrorMsg({
-            status,
-            statusText,
-            errorText,
-            url,
-          }));
+          const trace =
+              response.headers && response.headers.get('X-Gerrit-Trace');
+          if (response.status === 404) {
+            this._showNotFoundMessageWithTip({
+              status,
+              statusText,
+              errorText,
+              url,
+              trace,
+            });
+          } else {
+            this._showErrorDialog(this._constructServerErrorMsg({
+              status,
+              statusText,
+              errorText,
+              url,
+              trace,
+            }));
+          }
         }
         console.log(`server error: ${errorText}`);
       });
     }
 
-    _constructServerErrorMsg({errorText, status, statusText, url}) {
-      let err = `Error ${status}`;
+    _showNotFoundMessageWithTip({status, statusText, errorText, url, trace}) {
+      this.$.restAPI.getLoggedIn().then(isLoggedIn => {
+        const tip = isLoggedIn ?
+          'You might have not enough privileges.' :
+          'You might have not enough privileges. Sign in and try again.';
+        this._showErrorDialog(this._constructServerErrorMsg({
+          status,
+          statusText,
+          errorText,
+          url,
+          trace,
+          tip,
+        }), {
+          showSignInButton: !isLoggedIn,
+        });
+      });
+      return;
+    }
+
+    _constructServerErrorMsg({errorText, status, statusText, url, trace, tip}) {
+      let err = '';
+      if (tip) {
+        err += `${tip}\n\n`;
+      }
+      err += `Error ${status}`;
       if (statusText) { err += ` (${statusText})`; }
       if (errorText || url) { err += ': '; }
       if (errorText) { err += errorText; }
       if (url) { err += `\nEndpoint: ${url}`; }
+      if (trace) { err += `\nTrace Id: ${trace}`; }
       return err;
     }
 
@@ -342,12 +384,14 @@
       this.$.errorOverlay.close();
     }
 
-    _showErrorDialog(message) {
+    _showErrorDialog(message, opt_options) {
       this.$.reporting.reportErrorDialog(message);
       this.$.errorDialog.text = message;
+      this.$.errorDialog.showSignInButton =
+          opt_options && opt_options.showSignInButton;
       this.$.errorOverlay.open();
     }
   }
 
   customElements.define(GrErrorManager.is, GrErrorManager);
-})();
\ No newline at end of file
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 4f9b103..8296804 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -140,6 +140,36 @@
           url,
         }), 'Error 409 (Conflict): change conflicts' +
         '\nEndpoint: /my/test/url');
+        assert.equal(element._constructServerErrorMsg({
+          status,
+          statusText,
+          errorText,
+          url,
+          trace: 'xxxxx',
+        }), 'Error 409 (Conflict): change conflicts' +
+        '\nEndpoint: /my/test/url\nTrace Id: xxxxx');
+      });
+
+      test('extract trace id from headers if exists', done => {
+        const textSpy = sandbox.spy(
+            () => Promise.resolve('500')
+        );
+        const headers = new Headers();
+        headers.set('X-Gerrit-Trace', 'xxxx');
+        element.fire('server-error', {
+          response: {
+            headers,
+            status: 500,
+            text: textSpy,
+          },
+        });
+        flush(() => {
+          assert.equal(
+              element.$.errorDialog.text,
+              'Error 500: 500\nTrace Id: xxxx'
+          );
+          done();
+        });
       });
 
       test('suppress TOO_MANY_FILES error', done => {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index e208148..c438047 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -220,7 +220,7 @@
               [[_registerText]]
             </a>
           </div>
-          <a class="loginButton" href$="[[_loginURL]]">Sign in</a>
+          <a class="loginButton" href$="[[loginUrl]]">Sign in</a>
           <a
               class="settingsButton"
               href$="[[_generateSettingsLink()]]"
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 53c5ef5..05765fb 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -122,7 +122,7 @@
           computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
             '_topMenus, _docBaseUrl)',
         },
-        _loginURL: {
+        loginUrl: {
           type: String,
           value: '/login',
         },
@@ -162,36 +162,17 @@
       super.attached();
       this._loadAccount();
       this._loadConfig();
-      this.listen(window, 'location-change', '_handleLocationChange');
     }
 
     /** @override */
     detached() {
       super.detached();
-      this.unlisten(window, 'location-change', '_handleLocationChange');
     }
 
     reload() {
       this._loadAccount();
     }
 
-    _handleLocationChange(e) {
-      const baseUrl = this.getBaseUrl();
-      if (baseUrl) {
-        // Strip the canonical path from the path since needing canonical in
-        // the path is uneeded and breaks the url.
-        this._loginURL = baseUrl + '/login/' + encodeURIComponent(
-            '/' + window.location.pathname.substring(baseUrl.length) +
-            window.location.search +
-            window.location.hash);
-      } else {
-        this._loginURL = '/login/' + encodeURIComponent(
-            window.location.pathname +
-            window.location.search +
-            window.location.hash);
-      }
-    }
-
     _computeRelativeURL(path) {
       return '//' + window.location.host + this.getBaseUrl() + path;
     }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 42fc4a6..6c20a6a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -240,14 +240,20 @@
             <span class="separator"></span>
           </span>
           <a class="navLink"
+              title="[[createTitle(Shortcut.PREV_FILE,
+                    ShortcutSection.NAVIGATION)]]"
               href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]">
             Prev</a>
           <span class="separator"></span>
           <a class="navLink"
+              title="[[createTitle(Shortcut.UP_TO_CHANGE,
+                ShortcutSection.NAVIGATION)]]"
               href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]">
             Up</a>
           <span class="separator"></span>
           <a class="navLink"
+              title="[[createTitle(Shortcut.NEXT_FILE,
+                ShortcutSection.NAVIGATION)]]"
               href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]">
             Next</a>
         </div>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index cb89c0c..e91c8b0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -117,14 +117,24 @@
         // element for selected a file to view.
         _formattedFiles: {
           type: Array,
-          computed: '_formatFilesForDropdown(_fileList, ' +
+          computed: '_formatFilesForDropdown(_files, ' +
             '_patchRange.patchNum, _changeComments)',
         },
         // An sorted array of files, as returned by the rest API.
         _fileList: {
           type: Array,
-          value() { return []; },
+          computed: '_getSortedFileList(_files)',
         },
+        /**
+         * Contains information about files as returned by the rest API.
+         *
+         * @type {{ sortedFileList: Array<string>, changeFilesByPath: Object }}
+         */
+        _files: {
+          type: Object,
+          value() { return {sortedFileList: [], changeFilesByPath: {}}; },
+        },
+
         _path: {
           type: String,
           observer: '_pathChanged',
@@ -212,7 +222,7 @@
     static get observers() {
       return [
         '_getProjectConfig(_change.project)',
-        '_getFiles(_changeNum, _patchRange.*)',
+        '_getFiles(_changeNum, _patchRange.*, _changeComments)',
         '_setReviewedObserver(_loggedIn, params.*, _prefs)',
       ];
     }
@@ -293,23 +303,31 @@
       return this.$.restAPI.getChangeEdit(this._changeNum);
     }
 
-    _getFiles(changeNum, patchRangeRecord) {
+    _getSortedFileList(files) {
+      return files.sortedFileList;
+    }
+
+    _getFiles(changeNum, patchRangeRecord, changeComments) {
       // Polymer 2: check for undefined
-      if ([changeNum, patchRangeRecord, patchRangeRecord.base]
+      if ([changeNum, patchRangeRecord, patchRangeRecord.base, changeComments]
           .some(arg => arg === undefined)) {
-        return;
+        return Promise.resolve();
       }
 
       const patchRange = patchRangeRecord.base;
-      return this.$.restAPI.getChangeFilePathsAsSpeciallySortedArray(
-          changeNum, patchRange).then(files => {
-        this._fileList = files;
-
-        // in case current file is not in changed files
-        // (file has no change but has comments)
-        if (this._path && !this._fileList.includes(this._path)) {
-          this._fileList.push(this._path);
-        }
+      return this.$.restAPI.getChangeFiles(
+          changeNum, patchRange).then(changeFiles => {
+        if (!changeFiles) return;
+        const commentedPaths = changeComments.getPaths(patchRange);
+        const files = Object.assign({}, changeFiles);
+        Object.keys(commentedPaths).forEach(commentedPath => {
+          if (files.hasOwnProperty(commentedPath)) { return; }
+          files[commentedPath] = {status: 'U'};
+        });
+        this._files = {
+          sortedFileList: Object.keys(files).sort(this.specialFilePathCompare),
+          changeFilesByPath: files,
+        };
       });
     }
 
@@ -852,31 +870,31 @@
       return this._getChangePath(change, patchRangeRecord.base, revisions);
     }
 
-    _formatFilesForDropdown(fileList, patchNum, changeComments) {
+    _formatFilesForDropdown(files, patchNum, changeComments) {
       // Polymer 2: check for undefined
       if ([
-        fileList,
+        files,
         patchNum,
         changeComments,
       ].some(arg => arg === undefined)) {
         return;
       }
 
-      if (!fileList) { return; }
+      if (!files) { return; }
       const dropdownContent = [];
-      for (const path of fileList) {
+      for (const path of files.sortedFileList) {
         dropdownContent.push({
           text: this.computeDisplayPath(path),
           mobileText: this.computeTruncatedPath(path),
           value: path,
           bottomText: this._computeCommentString(changeComments, patchNum,
-              path),
+              path, files.changeFilesByPath[path]),
         });
       }
       return dropdownContent;
     }
 
-    _computeCommentString(changeComments, patchNum, path) {
+    _computeCommentString(changeComments, patchNum, path, changeFileInfo) {
       const unresolvedCount = changeComments.computeUnresolvedNum(patchNum,
           path);
       const commentCount = changeComments.computeCommentCount(patchNum, path);
@@ -885,11 +903,13 @@
       const unresolvedString = GrCountStringFormatter.computeString(
           unresolvedCount, 'unresolved');
 
-      return commentString +
-          // Add a space if both comments and unresolved
-          (commentString && unresolvedString ? ', ' : '') +
-          // Add parentheses around unresolved if it exists.
-          (unresolvedString ? `${unresolvedString}` : '');
+      const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes': '';
+
+      return [
+        unmodifiedString,
+        commentString,
+        unresolvedString]
+          .filter(v => v && v.length > 0).join(', ');
     }
 
     _computePrefsButtonHidden(prefs, prefsDisabled) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index bd172a5..1c32407 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -76,6 +76,17 @@
 
     const PARENT = 'PARENT';
 
+    function getFilesFromFileList(fileList) {
+      const changeFilesByPath = fileList.reduce((files, path) => {
+        files[path] = {};
+        return files;
+      }, {});
+      return {
+        sortedFileList: fileList,
+        changeFilesByPath,
+      };
+    }
+
     setup(() => {
       sandbox = sinon.sandbox.create();
 
@@ -135,7 +146,8 @@
           a: {_number: 10, commit: {parents: []}},
         },
       };
-      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
       element._path = 'glados.txt';
       element.changeViewState.selectedFileIndex = 1;
       element._loggedIn = true;
@@ -242,7 +254,8 @@
           b: {_number: 5, commit: {parents: []}},
         },
       };
-      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
       element._path = 'glados.txt';
 
       const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
@@ -309,7 +322,8 @@
           b: {_number: 2, commit: {parents: []}},
         },
       };
-      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
       element._path = 'glados.txt';
 
       const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
@@ -422,34 +436,26 @@
         unresolvedCountStub.withArgs(3, path).returns(2);
         unresolvedCountStub.withArgs(4, path).returns(0);
 
-        assert.equal(element._computeCommentString(comments, 1, path),
+        assert.equal(element._computeCommentString(comments, 1, path, {}),
             '1 unresolved');
-        assert.equal(element._computeCommentString(comments, 2, path),
+        assert.equal(
+            element._computeCommentString(comments, 2, path, {status: 'M'}),
             '1 comment');
-        assert.equal(element._computeCommentString(comments, 3, path),
+        assert.equal(
+            element._computeCommentString(comments, 2, path, {status: 'U'}),
+            'no changes, 1 comment');
+        assert.equal(
+            element._computeCommentString(comments, 3, path, {status: 'A'}),
             '2 comments, 2 unresolved');
-        assert.equal(element._computeCommentString(comments, 4, path), '');
+        assert.equal(
+            element._computeCommentString(comments, 4, path, {status: 'M'}), '');
+        assert.equal(
+            element._computeCommentString(comments, 4, path, {status: 'U'}),
+            'no changes');
         done();
       });
     });
 
-    suite('unchanged file', () => {
-      test('unchanged file should be included in _fileList', done => {
-        element._path = 'aaa.txt';
-        // trigger reload on files
-        element._changeNum = '123';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: '1',
-        };
-        assert.isFalse(element._fileList.includes('aaa.txt'));
-        flush(() => {
-          assert.isTrue(element._fileList.includes('aaa.txt'));
-          done();
-        });
-      });
-    });
-
     suite('url params', () => {
       setup(() => {
         sandbox.stub(
@@ -469,8 +475,9 @@
           patchNum: '10',
         };
         element._change = {_number: 42};
-        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md',
-          '/COMMIT_MSG', '/MERGE_LIST'];
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md',
+              '/COMMIT_MSG', '/MERGE_LIST']);
         element._path = 'glados.txt';
         const expectedFormattedFiles = [
           {
@@ -519,7 +526,8 @@
             a: {_number: 10, commit: {parents: []}},
           },
         };
-        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md']);
         element._path = 'glados.txt';
         flushAsynchronousOperations();
         const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
@@ -561,7 +569,8 @@
             b: {_number: 10, commit: {parents: []}},
           },
         };
-        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md']);
         element._path = 'glados.txt';
         flushAsynchronousOperations();
         const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
@@ -988,9 +997,9 @@
         setup(() => {
           navToChangeStub = sandbox.stub(element, '_navToChangeView');
           navToDiffStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-          element._fileList = [
+          element._files = getFilesFromFileList([
             'path/one.jpg', 'path/two.m4v', 'path/three.wav',
-          ];
+          ]);
           element._patchRange = {patchNum: '2', basePatchNum: '1'};
         });
 
@@ -1141,7 +1150,7 @@
     });
 
     test('shift+m navigates to next unreviewed file', () => {
-      element._fileList = ['file1', 'file2', 'file3'];
+      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
       element._reviewedFiles = new Set(['file1', 'file2']);
       element._path = 'file1';
       const reviewedStub = sandbox.stub(element, '_setReviewed');
@@ -1158,7 +1167,7 @@
     });
 
     test('File change should trigger navigateToDiff once', () => {
-      element._fileList = ['file1', 'file2', 'file3'];
+      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
       sandbox.stub(element, '_getLineOfInterest');
       sandbox.stub(element, '_initCursor');
       sandbox.stub(Gerrit.Nav, 'navigateToDiff');
@@ -1285,4 +1294,54 @@
           '/changes/test~12/revisions/1/patch?zip&path=index.php');
     });
   });
+
+  suite('gr-diff-view tests unmodified files with comments', () => {
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      const changedFiles = {
+        'file1.txt': {},
+        'a/b/test.c': {},
+      };
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({change: {}}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getProjectConfig() { return Promise.resolve({}); },
+        getDiffChangeDetail() { return Promise.resolve({}); },
+        getChangeFiles() { return Promise.resolve(changedFiles); },
+        saveFileReviewed() { return Promise.resolve(); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+        getReviewedFiles() { return Promise.resolve([]); },
+      });
+      element = fixture('basic');
+      return element._loadComments();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_getFiles add files with comments without changes', () => {
+      const patchChangeRecord = {
+        base: {
+          basePatchNum: '5',
+          patchNum: '10',
+        },
+      };
+      const changeComments = {
+        getPaths: sandbox.stub().returns({'file2.txt': {}, 'file1.txt': {}}),
+      };
+      return element._getFiles(23, patchChangeRecord, changeComments).then(() => {
+        assert.deepEqual(element._files, {
+          sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
+          changeFilesByPath: {
+            'file1.txt': {},
+            'file2.txt': {status: 'U'},
+            'a/b/test.c': {},
+          },
+        });
+      });
+    });
+  });
 </script>
diff --git a/polygerrit-ui/app/elements/gr-app-element.html b/polygerrit-ui/app/elements/gr-app-element.html
index f758280..6c334b5 100644
--- a/polygerrit-ui/app/elements/gr-app-element.html
+++ b/polygerrit-ui/app/elements/gr-app-element.html
@@ -126,7 +126,9 @@
       <gr-main-header
           id="mainHeader"
           search-query="{{params.query}}"
-          on-mobile-search="_mobileSearchToggle">
+          on-mobile-search="_mobileSearchToggle"
+          login-url="[[_loginUrl]]"
+      >
       </gr-main-header>
     </gr-fixed-panel>
     <main>
@@ -222,7 +224,7 @@
       </gr-registration-dialog>
     </gr-overlay>
     <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
-    <gr-error-manager id="errorManager"></gr-error-manager>
+    <gr-error-manager id="errorManager" login-url="[[_loginUrl]]"></gr-error-manager>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
     <gr-router id="router"></gr-router>
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index c1e317a..7e62b9d 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -94,6 +94,15 @@
           type: Boolean,
           value: false,
         },
+
+        /**
+         * Other elements in app must open this URL when
+         * user login is required.
+         */
+        _loginUrl: {
+          type: String,
+          value: '/login',
+        },
       };
     }
 
@@ -127,11 +136,14 @@
           e => this._handleLocationChange(e));
       this.addEventListener('rpc-log',
           e => this._handleRpcLog(e));
+      this.addEventListener('shortcut-triggered',
+          e => this._handleShortcutTriggered(e));
     }
 
     /** @override */
     ready() {
       super.ready();
+      this._updateLoginUrl();
       this.$.reporting.appStarted();
       this.$.router.start();
 
@@ -356,6 +368,22 @@
       this.$.header.unfloat();
     }
 
+    _handleShortcutTriggered(event) {
+      const {event: e, goKey} = event.detail;
+      // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
+      let key = `${e.key}:${e.type}`;
+      if (goKey) key = 'g+' + key;
+      if (e.shiftKey) key = 'shift+' + key;
+      if (e.ctrlKey) key = 'ctrl+' + key;
+      if (e.metaKey) key = 'meta+' + key;
+      if (e.altKey) key = 'alt+' + key;
+      this.$.reporting.reportInteraction('shortcut-triggered', {
+        key,
+        from: event.path && event.path[0]
+          && event.path[0].nodeName || 'unknown',
+      });
+    }
+
     _handlePageError(e) {
       const props = [
         '_showChangeListView',
@@ -385,6 +413,8 @@
     }
 
     _handleLocationChange(e) {
+      this._updateLoginUrl();
+
       const hash = e.detail.hash.substring(1);
       let pathname = e.detail.pathname;
       if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
@@ -393,6 +423,23 @@
       this.set('_path', pathname);
     }
 
+    _updateLoginUrl() {
+      const baseUrl = this.getBaseUrl();
+      if (baseUrl) {
+        // Strip the canonical path from the path since needing canonical in
+        // the path is uneeded and breaks the url.
+        this._loginUrl = baseUrl + '/login/' + encodeURIComponent(
+            '/' + window.location.pathname.substring(baseUrl.length) +
+            window.location.search +
+            window.location.hash);
+      } else {
+        this._loginUrl = '/login/' + encodeURIComponent(
+            window.location.pathname +
+            window.location.search +
+            window.location.hash);
+      }
+    }
+
     _paramsChanged(paramsRecord) {
       const params = paramsRecord.base;
       const viewsToCheck = [Gerrit.Nav.View.SEARCH, Gerrit.Nav.View.DASHBOARD];
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
index 333ca9d..31de1ce 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
@@ -88,7 +88,7 @@
       span.date:hover {
         text-decoration: underline;
       }
-      .actions {
+      .actions, .robotActions {
         display: flex;
         justify-content: flex-end;
         padding-top: 0;
@@ -96,17 +96,6 @@
       .action {
         margin-left: var(--spacing-l);
       }
-      .robotActions {
-        display: flex;
-        justify-content: flex-start;
-        padding-top: var(--spacing-m);
-        border-top: 1px solid var(--border-color);
-      }
-      .robotActions .action {
-        /* Keep button text lined up with output text */
-        margin-left: -4px;
-        margin-right: var(--spacing-l);
-      }
       .rightActions {
         display: flex;
         justify-content: flex-end;
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 ce26bf6..f021bf0 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
@@ -1172,19 +1172,6 @@
       return this.getChangeFiles(changeNum, patchRange);
     }
 
-    /**
-     * The closure compiler doesn't realize this.specialFilePathCompare is
-     * valid.
-     *
-     * @suppress {checkTypes}
-     */
-    getChangeFilePathsAsSpeciallySortedArray(changeNum, patchRange) {
-      return this.getChangeFiles(changeNum, patchRange).then(files => {
-        if (!files) return;
-        return Object.keys(files).sort(this.specialFilePathCompare);
-      });
-    }
-
     getChangeRevisionActions(changeNum, patchNum) {
       const req = {
         changeNum,
@@ -2770,4 +2757,4 @@
   }
 
   customElements.define(GrRestApiInterface.is, GrRestApiInterface);
-})();
\ No newline at end of file
+})();
diff --git a/tools/BUILD b/tools/BUILD
index 2d702fe..7f890b1 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -86,8 +86,8 @@
         "-Xep:TypeParameterShadowing:ERROR",
         "-Xep:TypeParameterUnusedInFormals:ERROR",
         "-Xep:URLEqualsHashCode:ERROR",
-        "-Xep:UnusedException:ERROR",
         "-Xep:UnsynchronizedOverridesSynchronized:ERROR",
+        "-Xep:UnusedException:ERROR",
         "-Xep:WaitNotInLoop:ERROR",
         "-Xep:WildcardImport:ERROR",
     ],