Merge "SubmitStrategy: Use directly SubmoduleCommit"
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index adb5d20..91bc476 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -375,93 +375,4 @@
 Note: TODO
 
 === url
-`plugin.url(opt_path)`
-
-Note: TODO
-
-[[deprecated-api]]
-== Deprecated APIs
-
-Some of the deprecated APIs have limited implementation in PolyGerrit to serve
-as a "stepping stone" to allow gradual migration.
-
-=== install
-`plugin.deprecated.install()`
-
-.Params:
-- none
-
-Replaces plugin APIs with a deprecated version. This allows use of deprecated
-APIs without changing JS code. For example, `onAction` is not available by
-default, and after `plugin.deprecated.install()` it's accessible via
-`self.onAction()`.
-
-=== onAction
-`plugin.deprecated.onAction(type, view_name, callback)`
-
-.Params:
-- `*string* type` Action type.
-- `*string* view_name` REST API action.
-- `*function(actionContext)* callback` Callback invoked on action button click.
-
-Adds a button to the UI with a click callback. Exact button location depends on
-parameters. Callback is triggered with an instance of
-link:#deprecated-action-context[action context].
-
-Support is limited:
-
-- type is either `change` or `revision`.
-
-See link:js-api.html#self_onAction[self.onAction] for more info.
-
-=== panel
-`plugin.deprecated.panel(extensionpoint, callback)`
-
-.Params:
-- `*string* extensionpoint`
-- `*function(screenContext)* callback`
-
-Adds a UI DOM element and triggers a callback with context to allow direct DOM
-access.
-
-Support is limited:
-
-- extensionpoint is one of the following:
- * CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK
- * CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK
-
-See link:js-api.html#self_panel[self.panel] for more info.
-
-=== settingsScreen
-`plugin.deprecated.settingsScreent(path, menu, callback)`
-
-.Params:
-- `*string* path` URL path fragment of the screen for direct link.
-- `*string* menu` Menu item title.
-- `*function(settingsScreenContext)* callback`
-
-Adds a settings menu item and a section in the settings screen that is provided
-to plugin for setup.
-
-See link:js-api.html#self_settingsScreen[self.settingsScreen] for more info.
-
-[[deprecated-action-context]]
-=== Action Context (deprecated)
-Instance of Action Context is passed to `onAction()` callback.
-
-Support is limited:
-
-- `popup()`
-- `hide()`
-- `refresh()`
-- `textfield()`
-- `br()`
-- `msg()`
-- `div()`
-- `button()`
-- `checkbox()`
-- `label()`
-- `prependLabel()`
-- `call()`
-
-See link:js-api.html#ActionContext[Action Context] for more info.
+`plugin.url(opt_path)`
\ No newline at end of file
diff --git a/Documentation/pg-plugin-migration.txt b/Documentation/pg-plugin-migration.txt
index bca4b7a..061c687 100644
--- a/Documentation/pg-plugin-migration.txt
+++ b/Documentation/pg-plugin-migration.txt
@@ -79,9 +79,6 @@
   <script>
     Gerrit.install(plugin => {
         // Setup block, is executed before sampleplugin.js
-
-        // Install deprecated JS APIs (onAction, popup, etc)
-        plugin.deprecated.install();
     });
   </script>
 
@@ -105,8 +102,6 @@
 - `sampleplugin.js` is loaded since it's referenced in `sampleplugin.html`
 - setup script tag code is executed before `sampleplugin.js`
 - cleanup script tag code is executed after `sampleplugin.js`
-- `plugin.deprecated.install()` enables deprecated APIs (onAction(), popup(),
-etc) before `sampleplugin.js` is loaded
 
 This means the plugin instance is shared between .html-based and .js-based
 code. This allows to gradually and incrementally transfer code to the new API.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index e09d936..f2f5c15 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2039,6 +2039,10 @@
 comments for each path are sorted by patch set number. Each comment has
 the `patch_set` and `author` fields set.
 
+If the `enable_context` request parameter is set to true, the comment entries
+will contain a list of link:#context-line[ContextLine] containing the lines of
+the source file where the comment was written.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/comments HTTP/1.0
@@ -6676,6 +6680,11 @@
 |`commit_id` |optional|
 Hex commit SHA1 (40 characters string) of the commit of the patchset to which
 this comment applies.
+|`context_lines` |optional|
+A list of link:#context-line[ContextLine] containing the lines of the source
+file where the comment was written. Available only if the "enable_context"
+parameter (see link:#list-change-comments[List Change Comments]) is set.
+
 |===========================
 
 [[comment-input]]
@@ -6747,6 +6756,18 @@
 |`end_character`     ||The character position in the end line. (0-based)
 |===========================
 
+[[context-line]]
+=== ContextLine
+The `ContextLine` entity contains the line number and line text of a single
+line of the source file content.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name          |Description
+|`line_number`       |The line number of the source line.
+|`context_line`      |String containing the line text.
+|===========================
+
 [[commit-info]]
 === CommitInfo
 The `CommitInfo` entity contains information about a commit.
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index e8b58f9..3b3a5fc 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -326,8 +326,12 @@
    * @return comments in a map keyed by path; comments have the {@code revision} field set to
    *     indicate their patch set.
    * @throws RestApiException
+   * @deprecated Callers should use {@link #commentsRequest()} instead
    */
-  Map<String, List<CommentInfo>> comments() throws RestApiException;
+  @Deprecated
+  default Map<String, List<CommentInfo>> comments() throws RestApiException {
+    return commentsRequest().get();
+  }
 
   /**
    * Get all published comments on a change as a list.
@@ -335,8 +339,21 @@
    * @return comments as a list; comments have the {@code revision} field set to indicate their
    *     patch set.
    * @throws RestApiException
+   * @deprecate Callers should use {@link #commentsRequest()} instead
    */
-  List<CommentInfo> commentsAsList() throws RestApiException;
+  @Deprecated
+  default List<CommentInfo> commentsAsList() throws RestApiException {
+    return commentsRequest().getAsList();
+  }
+
+  /**
+   * Get a {@link CommentsRequest} entity that can be used to retrieve published comments.
+   *
+   * @return A {@link CommentsRequest} entity that can be used to retrieve the comments using the
+   *     {@link CommentsRequest#get()} or {@link CommentsRequest#getAsList()}.
+   * @throws RestApiException
+   */
+  CommentsRequest commentsRequest() throws RestApiException;
 
   /**
    * Get all robot comments on a change.
@@ -395,6 +412,42 @@
    */
   ChangeMessageApi message(String id) throws RestApiException;
 
+  abstract class CommentsRequest {
+    private boolean enableContext;
+
+    /**
+     * Get all published comments on a change.
+     *
+     * @return comments in a map keyed by path; comments have the {@code revision} field set to
+     *     indicate their patch set.
+     * @throws RestApiException
+     */
+    public abstract Map<String, List<CommentInfo>> get() throws RestApiException;
+
+    /**
+     * Get all published comments on a change as a list.
+     *
+     * @return comments as a list; comments have the {@code revision} field set to indicate their
+     *     patch set.
+     * @throws RestApiException
+     */
+    public abstract List<CommentInfo> getAsList() throws RestApiException;
+
+    public CommentsRequest withContext(boolean enableContext) {
+      this.enableContext = enableContext;
+      return this;
+    }
+
+    public CommentsRequest withContext() {
+      this.enableContext = true;
+      return this;
+    }
+
+    public boolean getContext() {
+      return enableContext;
+    }
+  }
+
   abstract class SuggestedReviewersRequest {
     private String query;
     private int limit;
@@ -603,16 +656,23 @@
     }
 
     @Override
+    @Deprecated
     public Map<String, List<CommentInfo>> comments() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    @Deprecated
     public List<CommentInfo> commentsAsList() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public CommentsRequest commentsRequest() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/CommentInput.java b/java/com/google/gerrit/extensions/api/changes/CommentInput.java
new file mode 100644
index 0000000..4e2f033
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/CommentInput.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+/** Input to the {@link ChangeApi#comments(CommentInput)}. */
+public class CommentInput {
+  public boolean enableContext;
+}
diff --git a/java/com/google/gerrit/extensions/common/CommentInfo.java b/java/com/google/gerrit/extensions/common/CommentInfo.java
index 19e002a..19d721b 100644
--- a/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.client.Comment;
+import java.util.List;
 import java.util.Objects;
 
 public class CommentInfo extends Comment {
@@ -22,6 +23,12 @@
   public String tag;
   public String changeMessageId;
 
+  /**
+   * A list of {@link ContextLineInfo}, that is, a list of pairs of {line_num, line_text} of the
+   * actual source file content surrounding and including the lines where the comment was written.
+   */
+  public List<ContextLineInfo> contextLines;
+
   @Override
   public boolean equals(Object o) {
     if (super.equals(o)) {
diff --git a/java/com/google/gerrit/extensions/common/ContextLineInfo.java b/java/com/google/gerrit/extensions/common/ContextLineInfo.java
new file mode 100644
index 0000000..3062e85
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ContextLineInfo.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.Objects;
+
+/**
+ * An entity class representing 1 line of context {line number, line text} of the source file where
+ * a comment was written.
+ */
+public class ContextLineInfo {
+  public int lineNumber;
+  public String contextLine;
+
+  public ContextLineInfo() {}
+
+  public ContextLineInfo(int lineNumber, String contextLine) {
+    this.lineNumber = lineNumber;
+    this.contextLine = contextLine;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ContextLineInfo) {
+      ContextLineInfo l = (ContextLineInfo) o;
+      return lineNumber == l.lineNumber && contextLine.equals(l.contextLine);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(lineNumber, contextLine);
+  }
+}
diff --git a/java/com/google/gerrit/server/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
new file mode 100644
index 0000000..813dad7
--- /dev/null
+++ b/java/com/google/gerrit/server/CommentContextLoader.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.Text;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * Computes the list of {@link ContextLineInfo} for a given comment, that is, the lines of the
+ * source file surrounding and including the area where the comment was written.
+ */
+public class CommentContextLoader {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final GitRepositoryManager repoManager;
+  private final Project.NameKey project;
+  private Map<ContextData, List<ContextLineInfo>> candidates;
+
+  public interface Factory {
+    CommentContextLoader create(Project.NameKey project);
+  }
+
+  @Inject
+  public CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
+    this.repoManager = repoManager;
+    this.project = project;
+    this.candidates = new HashMap<>();
+  }
+
+  /**
+   * Returns an empty list of {@link ContextLineInfo}. Clients are expected to call this method one
+   * or more times. Each call returns a reference to an empty {@link List<ContextLineInfo>}.
+   *
+   * <p>A single call to {@link #fill()} will cause all list references returned from this method to
+   * be populated. If a client calls this method again with a comment that was passed before calling
+   * {@link #fill()}, the new populated list will be returned.
+   *
+   * @param comment the comment entity for which we want to load the context
+   * @return a list of {@link ContextLineInfo}
+   */
+  public List<ContextLineInfo> getContext(CommentInfo comment) {
+    ContextData key =
+        ContextData.create(
+            comment.id,
+            ObjectId.fromString(comment.commitId),
+            comment.path,
+            getStartAndEndLines(comment));
+    List<ContextLineInfo> context = candidates.get(key);
+    if (context == null) {
+      context = new ArrayList<>();
+      candidates.put(key, context);
+    }
+    return context;
+  }
+
+  /**
+   * A call to this method loads the context for all comments stored in {@link
+   * CommentContextLoader#candidates}. This is useful so that the repository is opened once for all
+   * comments.
+   */
+  public void fill() {
+    // Group comments by commit ID so that each commit is parsed only once
+    Map<ObjectId, List<ContextData>> commentsByCommitId =
+        candidates.keySet().stream().collect(groupingBy(ContextData::commitId));
+
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      for (ObjectId commitId : commentsByCommitId.keySet()) {
+        RevCommit commit = rw.parseCommit(commitId);
+        for (ContextData k : commentsByCommitId.get(commitId)) {
+          if (!k.range().isPresent()) {
+            continue;
+          }
+          try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), k.path(), commit.getTree())) {
+            if (tw == null) {
+              logger.atWarning().log(
+                  "Failed to find path %s in the git tree of ID %s.",
+                  k.path(), commit.getTree().getId());
+              continue;
+            }
+            ObjectId id = tw.getObjectId(0);
+            Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
+            List<ContextLineInfo> contextLines = candidates.get(k);
+            Range r = k.range().get();
+            for (int i = r.start(); i <= r.end(); i++) {
+              contextLines.add(new ContextLineInfo(i, src.getString(i - 1)));
+            }
+          }
+        }
+      }
+    } catch (IOException e) {
+      throw new StorageException("Failed to load the comment context", e);
+    }
+  }
+
+  private static Optional<Range> getStartAndEndLines(CommentInfo comment) {
+    if (comment.range != null) {
+      return Optional.of(Range.create(comment.range.startLine, comment.range.endLine));
+    } else if (comment.line != null) {
+      return Optional.of(Range.create(comment.line, comment.line));
+    }
+    return Optional.empty();
+  }
+
+  @AutoValue
+  abstract static class Range {
+    static Range create(int start, int end) {
+      return new AutoValue_CommentContextLoader_Range(start, end);
+    }
+
+    abstract int start();
+
+    abstract int end();
+  }
+
+  @AutoValue
+  abstract static class ContextData {
+    static ContextData create(String id, ObjectId commitId, String path, Optional<Range> range) {
+      return new AutoValue_CommentContextLoader_ContextData(id, commitId, path, range);
+    }
+
+    abstract String id();
+
+    abstract ObjectId commitId();
+
+    abstract String path();
+
+    abstract Optional<Range> range();
+  }
+}
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index b4a5da7..0992bcd 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -158,7 +158,7 @@
   private final GetAssignee getAssignee;
   private final GetPastAssignees getPastAssignees;
   private final DeleteAssignee deleteAssignee;
-  private final ListChangeComments listComments;
+  private final Provider<ListChangeComments> listCommentsProvider;
   private final ListChangeRobotComments listChangeRobotComments;
   private final ListChangeDrafts listDrafts;
   private final ChangeEditApiImpl.Factory changeEditApi;
@@ -211,7 +211,7 @@
       GetAssignee getAssignee,
       GetPastAssignees getPastAssignees,
       DeleteAssignee deleteAssignee,
-      ListChangeComments listComments,
+      Provider<ListChangeComments> listCommentsProvider,
       ListChangeRobotComments listChangeRobotComments,
       ListChangeDrafts listDrafts,
       ChangeEditApiImpl.Factory changeEditApi,
@@ -262,7 +262,7 @@
     this.getAssignee = getAssignee;
     this.getPastAssignees = getPastAssignees;
     this.deleteAssignee = deleteAssignee;
-    this.listComments = listComments;
+    this.listCommentsProvider = listCommentsProvider;
     this.listChangeRobotComments = listChangeRobotComments;
     this.listDrafts = listDrafts;
     this.changeEditApi = changeEditApi;
@@ -599,21 +599,30 @@
   }
 
   @Override
-  public Map<String, List<CommentInfo>> comments() throws RestApiException {
-    try {
-      return listComments.apply(change).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get comments", e);
-    }
-  }
+  public CommentsRequest commentsRequest() throws RestApiException {
+    return new CommentsRequest() {
+      @Override
+      public Map<String, List<CommentInfo>> get() throws RestApiException {
+        try {
+          ListChangeComments listComments = listCommentsProvider.get();
+          listComments.setContext(this.getContext());
+          return listComments.apply(change).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get comments", e);
+        }
+      }
 
-  @Override
-  public List<CommentInfo> commentsAsList() throws RestApiException {
-    try {
-      return listComments.getComments(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get comments", e);
-    }
+      @Override
+      public List<CommentInfo> getAsList() throws RestApiException {
+        try {
+          ListChangeComments listComments = listCommentsProvider.get();
+          listComments.setContext(this.getContext());
+          return listComments.getComments(change);
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get comments", e);
+        }
+      }
+    };
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 20c4b48..67049e8 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
@@ -34,6 +35,7 @@
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
@@ -48,10 +50,15 @@
 
   private boolean fillAccounts = true;
   private boolean fillPatchSet;
+  private CommentContextLoader.Factory commentContextLoaderFactory;
+  private CommentContextLoader commentContextLoader;
 
   @Inject
-  CommentJson(AccountLoader.Factory accountLoaderFactory) {
+  CommentJson(
+      AccountLoader.Factory accountLoaderFactory,
+      CommentContextLoader.Factory commentContextLoaderFactory) {
     this.accountLoaderFactory = accountLoaderFactory;
+    this.commentContextLoaderFactory = commentContextLoaderFactory;
   }
 
   CommentJson setFillAccounts(boolean fillAccounts) {
@@ -64,6 +71,13 @@
     return this;
   }
 
+  CommentJson setEnableContext(boolean enableContext, Project.NameKey project) {
+    if (enableContext) {
+      this.commentContextLoader = commentContextLoaderFactory.create(project);
+    }
+    return this;
+  }
+
   public HumanCommentFormatter newHumanCommentFormatter() {
     return new HumanCommentFormatter();
   }
@@ -79,6 +93,9 @@
       if (loader != null) {
         loader.fill();
       }
+      if (commentContextLoader != null) {
+        commentContextLoader.fill();
+      }
       return info;
     }
 
@@ -103,6 +120,9 @@
       if (loader != null) {
         loader.fill();
       }
+      if (commentContextLoader != null) {
+        commentContextLoader.fill();
+      }
       return out;
     }
 
@@ -118,6 +138,9 @@
       if (loader != null) {
         loader.fill();
       }
+      if (commentContextLoader != null) {
+        commentContextLoader.fill();
+      }
       return out;
     }
 
@@ -148,6 +171,9 @@
         r.author = loader.get(c.author.getId());
       }
       r.commitId = c.getCommitId().getName();
+      if (commentContextLoader != null) {
+        r.contextLines = commentContextLoader.getContext(r);
+      }
     }
 
     protected Range toRange(Comment.Range commentRange) {
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index b842f55..fec7bdc 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -19,38 +19,56 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.util.List;
 import java.util.Map;
+import org.kohsuke.args4j.Option;
 
-@Singleton
 public class ListChangeComments implements RestReadView<ChangeResource> {
   private final ChangeMessagesUtil changeMessagesUtil;
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
+  private final CommentContextLoader.Factory commentContextFactory;
+
+  private boolean includeContext;
+
+  /**
+   * Optional parameter. If set, the contextLines field of the {@link ContextLineInfo} of the
+   * response will contain the lines of the source file where the comment was written.
+   *
+   * @param context If true, comment context will be attached to the response
+   */
+  @Option(name = "--enable-context")
+  public void setContext(boolean context) {
+    this.includeContext = context;
+  }
 
   @Inject
   ListChangeComments(
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
-      ChangeMessagesUtil changeMessagesUtil) {
+      ChangeMessagesUtil changeMessagesUtil,
+      CommentContextLoader.Factory commentContextFactory) {
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.changeMessagesUtil = changeMessagesUtil;
+    this.commentContextFactory = commentContextFactory;
   }
 
   @Override
@@ -70,7 +88,8 @@
 
   private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
       throws PermissionBackendException {
-    ImmutableList<CommentInfo> commentInfos = getCommentFormatter().formatAsList(comments);
+    ImmutableList<CommentInfo> commentInfos =
+        getCommentFormatter(rsrc.getProject()).formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
     CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
     return commentInfos;
@@ -78,7 +97,8 @@
 
   private Map<String, List<CommentInfo>> getAsMap(
       Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
-    Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter().format(comments);
+    Map<String, List<CommentInfo>> commentInfosMap =
+        getCommentFormatter(rsrc.getProject()).format(comments);
     List<CommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
@@ -86,7 +106,12 @@
     return commentInfosMap;
   }
 
-  private CommentJson.HumanCommentFormatter getCommentFormatter() {
-    return commentJson.get().setFillAccounts(true).setFillPatchSet(true).newHumanCommentFormatter();
+  private CommentJson.HumanCommentFormatter getCommentFormatter(Project.NameKey project) {
+    return commentJson
+        .get()
+        .setFillAccounts(true)
+        .setFillPatchSet(true)
+        .setEnableContext(includeContext, project)
+        .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 62d89ee..69e2788 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -29,6 +29,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
@@ -205,6 +206,7 @@
     factory(AccountLoader.Factory.class);
     factory(ChangeInserter.Factory.class);
     factory(ChangeResource.Factory.class);
+    factory(CommentContextLoader.Factory.class);
     factory(DeleteChangeOp.Factory.class);
     factory(DeleteReviewerByEmailOp.Factory.class);
     factory(DeleteReviewerOp.Factory.class);
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index c7fe74e..cfd17f4 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -90,9 +90,12 @@
         command("git-receive-pack").to(Commands.key(git, "receive-pack"));
         command("gerrit-receive-pack").to(Commands.key(git, "receive-pack"));
         command(git, "receive-pack").to(Commands.key(gerrit, "receive-pack"));
-        command(gerrit, "test-submit").toProvider(new DispatchCommandProvider(testSubmit));
       }
     }
+
+    if (!slaveMode) {
+      command(gerrit, "test-submit").toProvider(new DispatchCommandProvider(testSubmit));
+    }
     command("suexec").to(SuExec.class);
     listener().to(ShowCaches.StartupListener.class);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 4f61d79..72453fd 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -30,6 +30,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -53,6 +54,7 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -79,6 +81,7 @@
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -1019,12 +1022,16 @@
     addComment(r1, "nit: trailing whitespace");
     addComment(r2, "typo: content");
 
-    Map<String, List<CommentInfo>> actual = gApi.changes().id(r2.getChangeId()).comments();
+    Map<String, List<CommentInfo>> actual =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().get();
     assertThat(actual.keySet()).containsExactly(FILE_NAME);
 
     List<CommentInfo> comments = actual.get(FILE_NAME);
     assertThat(comments).hasSize(2);
 
+    // Comment context is disabled by default
+    assertThat(comments.stream().filter(c -> c.contextLines != null)).isEmpty();
+
     CommentInfo c1 = comments.get(0);
     assertThat(c1.author._accountId).isEqualTo(user.id().get());
     assertThat(c1.patchSet).isEqualTo(1);
@@ -1041,6 +1048,61 @@
   }
 
   @Test
+  public void listChangeCommentsWithContextEnabled() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    addCommentOnLine(r2, "nit: please fix", 1);
+    addCommentOnRange(r2, "looks good", commentRangeInLines(2, 5));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(2);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("nit: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(contextLines("1", "line_1"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            contextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+  }
+
+  private List<ContextLineInfo> contextLines(String... args) {
+    List<ContextLineInfo> result = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      int lineNbr = Integer.parseInt(args[i]);
+      String contextLine = args[i + 1];
+      ContextLineInfo info = new ContextLineInfo(lineNbr, contextLine);
+      result.add(info);
+    }
+    return result;
+  }
+
+  @Test
   public void listChangeCommentsAnonymousDoesNotRequireAuth() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
@@ -1052,12 +1114,12 @@
     addComment(r1, "nit: trailing whitespace");
     addComment(r2, "typo: content");
 
-    List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsAsList();
+    List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList();
     assertThat(comments.stream().map(c -> c.message).collect(toList()))
         .containsExactly("nit: trailing whitespace", "typo: content");
 
     requestScopeOperations.setApiUserAnonymous();
-    comments = gApi.changes().id(r1.getChangeId()).commentsAsList();
+    comments = gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList();
     assertThat(comments.stream().map(c -> c.message).collect(toList()))
         .containsExactly("nit: trailing whitespace", "typo: content");
   }
@@ -1713,7 +1775,23 @@
   }
 
   private void addComment(PushOneCommit.Result r, String message) throws Exception {
-    addComment(r, message, false, false, null);
+    addComment(r, message, false, false, null, 1, null);
+  }
+
+  private void addCommentOnLine(PushOneCommit.Result r, String message, int line) throws Exception {
+    addComment(r, message, false, false, null, line, null);
+  }
+
+  private void addCommentOnRange(PushOneCommit.Result r, String message, Comment.Range range)
+      throws Exception {
+    addComment(r, message, false, false, null, null, range);
+  }
+
+  private Comment.Range commentRangeInLines(int startLine, int endLine) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.endLine = endLine;
+    return range;
   }
 
   private void addComment(
@@ -1723,12 +1801,25 @@
       Boolean unresolved,
       String inReplyTo)
       throws Exception {
+    addComment(r, message, omitDuplicateComments, unresolved, inReplyTo, 1, null);
+  }
+
+  private void addComment(
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo,
+      Integer line,
+      Comment.Range range)
+      throws Exception {
     CommentInput c = new CommentInput();
     c.line = 1;
     c.message = message;
     c.path = FILE_NAME;
     c.unresolved = unresolved;
     c.inReplyTo = inReplyTo;
+    c.range = range;
     ReviewInput in = newInput(c);
     in.omitDuplicateComments = omitDuplicateComments;
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
@@ -1766,11 +1857,11 @@
   }
 
   private List<CommentInfo> getPublishedCommentsAsList(String changeId) throws Exception {
-    return gApi.changes().id(changeId).commentsAsList();
+    return gApi.changes().id(changeId).commentsRequest().getAsList();
   }
 
   private List<CommentInfo> getPublishedCommentsAsList(Change.Id changeId) throws Exception {
-    return gApi.changes().id(changeId.get()).commentsAsList();
+    return gApi.changes().id(changeId.get()).commentsRequest().getAsList();
   }
 
   private Map<String, List<CommentInfo>> getDraftComments(String changeId, String revId)
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
index c29cf99..9161928 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
@@ -213,11 +213,16 @@
 
   @Test
   public void commentCanBeCreatedOnSecondParentCommit() throws Exception {
-    // Second parents only exist for merge commits. The test API currently doesn't support the
-    // creation of changes with merge commits yet, though. As there's no explicit validation keeping
-    // us from adding comments on the non-existing second parent of a regular commit, just use the
-    // latter. That's still better than not having this test at all.
-    Change.Id changeId = changeOperations.newChange().create();
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
 
     String commentUuid =
         changeOperations
@@ -233,12 +238,38 @@
   }
 
   @Test
+  public void commentCanBeCreatedOnNonExistingSecondParentCommit() throws Exception {
+    Change.Id parentChangeId = changeOperations.newChange().create();
+    Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newComment()
+            .onSecondParentCommit()
+            .create();
+
+    // We want to be able to create such invalid comments for testing purposes (e.g. testing error
+    // handling or resilience of an endpoint) and hence we need to allow such invalid comments in
+    // the test API.
+    CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isEqualTo(2);
+  }
+
+  @Test
   public void commentCanBeCreatedOnAutoMergeCommit() throws Exception {
-    // Second parents only exist for merge commits. The test API currently doesn't support the
-    // creation of changes with merge commits yet, though. As there's no explicit validation keeping
-    // us from adding comments on the non-existing second parent of a regular commit, just use the
-    // latter. That's still better than not having this test at all.
-    Change.Id changeId = changeOperations.newChange().create();
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
 
     String commentUuid =
         changeOperations
@@ -550,11 +581,16 @@
 
   @Test
   public void draftCommentCanBeCreatedOnSecondParentCommit() throws Exception {
-    // Second parents only exist for merge commits. The test API currently doesn't support the
-    // creation of changes with merge commits yet, though. As there's no explicit validation keeping
-    // us from adding comments on the non-existing second parent of a regular commit, just use the
-    // latter. That's still better than not having this test at all.
-    Change.Id changeId = changeOperations.newChange().create();
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
 
     String commentUuid =
         changeOperations
@@ -570,12 +606,38 @@
   }
 
   @Test
+  public void draftCommentCanBeCreatedOnNonExistingSecondParentCommit() throws Exception {
+    Change.Id parentChangeId = changeOperations.newChange().create();
+    Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+
+    String commentUuid =
+        changeOperations
+            .change(changeId)
+            .currentPatchset()
+            .newDraftComment()
+            .onSecondParentCommit()
+            .create();
+
+    // We want to be able to create such invalid comments for testing purposes (e.g. testing error
+    // handling or resilience of an endpoint) and hence we need to allow such invalid comments in
+    // the test API.
+    CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+    assertThat(comment).side().isEqualTo(Side.PARENT);
+    assertThat(comment).parent().isEqualTo(2);
+  }
+
+  @Test
   public void draftCommentCanBeCreatedOnAutoMergeCommit() throws Exception {
-    // Second parents only exist for merge commits. The test API currently doesn't support the
-    // creation of changes with merge commits yet, though. As there's no explicit validation keeping
-    // us from adding comments on the non-existing second parent of a regular commit, just use the
-    // latter. That's still better than not having this test at all.
-    Change.Id changeId = changeOperations.newChange().create();
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
 
     String commentUuid =
         changeOperations
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index c255e61..b3b2f5a 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -1,8 +1,9 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 junit_tests(
     name = "tests",
-    srcs = glob(["*.java"]),
+    srcs = glob(["*Test.java"]),
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
@@ -10,3 +11,9 @@
         "//lib/truth",
     ],
 )
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_cache",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
new file mode 100644
index 0000000..d8c6fe2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.server.ModuleImpl;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class PersistentCacheFactoryIT extends AbstractDaemonTest {
+
+  @Inject PersistentCacheFactory persistentCacheFactory;
+
+  @ModuleImpl(name = CacheModule.PERSISTENT_MODULE)
+  public static class Module extends AbstractModule {
+
+    @Override
+    protected void configure() {
+      bind(PersistentCacheFactory.class).to(TestCacheFactory.class);
+    }
+  }
+
+  @Override
+  public com.google.inject.Module createModule() {
+    return new Module();
+  }
+
+  @Test
+  public void shouldH2PersistentCacheBeReplaceableByADifferentCacheImplementation() {
+    assertThat(persistentCacheFactory).isInstanceOf(TestCacheFactory.class);
+  }
+
+  public static class TestCacheFactory implements PersistentCacheFactory {
+
+    private final MemoryCacheFactory memoryCacheFactory;
+
+    @Inject
+    TestCacheFactory(MemoryCacheFactory memoryCacheFactory) {
+      this.memoryCacheFactory = memoryCacheFactory;
+    }
+
+    @Override
+    public <K, V> com.google.common.cache.Cache<K, V> build(
+        PersistentCacheDef<K, V> def, CacheBackend backend) {
+      return memoryCacheFactory.build(def, backend);
+    }
+
+    @Override
+    public <K, V> LoadingCache<K, V> build(
+        PersistentCacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend) {
+      return memoryCacheFactory.build(def, loader, backend);
+    }
+
+    @Override
+    public void onStop(String plugin) {}
+  }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
index 538c78a..9eeddcc 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
@@ -18,7 +18,6 @@
 import {GrPluginRestApi} from '../shared/gr-js-api-interface/gr-plugin-rest-api';
 import {GrEventHelper} from './gr-event-helper/gr-event-helper';
 import {GrPopupInterface} from './gr-popup-interface/gr-popup-interface';
-import {GrPluginActionContext} from '../shared/gr-js-api-interface/gr-plugin-action-context';
 import {ConfigInfo} from '../../types/common';
 
 interface GerritElementExtensions {
@@ -85,7 +84,9 @@
 
 export interface PluginApi {
   _url?: URL;
-  deprecated: PluginDeprecatedApi;
+  popup(): Promise<GrPopupInterface>;
+  popup(moduleName: string): Promise<GrPopupInterface>;
+  popup(moduleName?: string): Promise<GrPopupInterface | null>;
   hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
   getPluginName(): string;
   on(eventName: string, target: any): void;
@@ -93,21 +94,3 @@
   restApi(): GrPluginRestApi;
   eventHelper(element: Node): GrEventHelper;
 }
-
-export interface PluginDeprecatedApi {
-  _loadedGwt(): void;
-  install: () => void;
-  popup(element: Node): GrPopupInterface;
-  onAction(
-    type: string,
-    action: string,
-    callback: (ctx: GrPluginActionContext) => void
-  ): void;
-  panel(extensionpoint: string, callback: (panel: PanelInfo) => void): void;
-  screen(pattern: string, callback: (settings: SettingsInfo) => void): void;
-  settingsScreen(
-    path: string,
-    menu: string,
-    callback: (settings: SettingsInfo) => void
-  ): void;
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index e856788..873cc4b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -20,7 +20,6 @@
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
 import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
 import {EventType} from '../../plugins/gr-plugin-types.js';
-import {GrPluginActionContext} from './gr-plugin-action-context.js';
 import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {getPluginLoader} from './gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
@@ -353,15 +352,6 @@
     assert.isOk(plugin.attributeHelper());
   });
 
-  test('deprecated.install', () => {
-    assert.notStrictEqual(plugin.popup, plugin.deprecated.popup);
-    assert.notStrictEqual(plugin.onAction, plugin.deprecated.onAction);
-    plugin.deprecated.install();
-    assert.strictEqual(plugin.popup, plugin.deprecated.popup);
-    assert.strictEqual(plugin.onAction, plugin.deprecated.onAction);
-    assert.notStrictEqual(plugin.install, plugin.deprecated.install);
-  });
-
   test('getAdminMenuLinks', () => {
     const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
     const getCallbacksStub = sinon.stub(element, '_getEventCallbacks')
@@ -415,56 +405,6 @@
       plugin.popup('some-name');
       assert.isTrue(openStub.calledOnce);
     });
-
-    test('deprecated.popup(element) creates popup with element', () => {
-      const el = document.createElement('div');
-      el.textContent = 'some text here';
-      const openStub = sinon.stub(GrPopupInterface.prototype, 'open');
-      openStub.returns(Promise.resolve({
-        _getElement() {
-          return document.createElement('div');
-        }}));
-      plugin.deprecated.popup(el);
-      assert.isTrue(openStub.calledOnce);
-    });
-  });
-
-  suite('onAction', () => {
-    let change;
-    let revision;
-    let actionDetails;
-
-    setup(() => {
-      change = {};
-      revision = {};
-      actionDetails = {__key: 'some'};
-      sinon.stub(plugin, 'on').callsArgWith(1, change, revision);
-      sinon.stub(plugin, 'changeActions').returns({
-        addTapListener: sinon.stub().callsArg(1),
-        getActionDetails: () => actionDetails,
-      });
-    });
-
-    test('returns GrPluginActionContext', () => {
-      const stub = sinon.stub();
-      plugin.deprecated.onAction('change', 'foo', ctx => {
-        assert.isTrue(ctx instanceof GrPluginActionContext);
-        assert.strictEqual(ctx.change, change);
-        assert.strictEqual(ctx.revision, revision);
-        assert.strictEqual(ctx.action, actionDetails);
-        assert.strictEqual(ctx.plugin, plugin);
-        stub();
-      });
-      assert.isTrue(stub.called);
-    });
-
-    test('other actions', () => {
-      const stub = sinon.stub();
-      plugin.deprecated.onAction('project', 'foo', stub);
-      plugin.deprecated.onAction('edit', 'foo', stub);
-      plugin.deprecated.onAction('branch', 'foo', stub);
-      assert.isFalse(stub.called);
-    });
   });
 
   suite('screen', () => {
@@ -480,18 +420,6 @@
       );
     });
 
-    test('deprecated works', () => {
-      const stub = sinon.stub();
-      const hookStub = {onAttached: sinon.stub()};
-      sinon.stub(plugin, 'hook').returns(hookStub);
-      plugin.deprecated.screen('foo', stub);
-      assert.isTrue(plugin.hook.calledWith('testplugin-screen-foo'));
-      const fakeEl = {style: {display: ''}};
-      hookStub.onAttached.callArgWith(0, fakeEl);
-      assert.isTrue(stub.called);
-      assert.equal(fakeEl.style.display, 'none');
-    });
-
     test('works', () => {
       sinon.stub(plugin, 'registerCustomComponent');
       plugin.screen('foo', 'some-module');
@@ -500,82 +428,11 @@
     });
   });
 
-  suite('panel', () => {
-    let fakeEl;
-    let emulateAttached;
-
-    setup(()=> {
-      fakeEl = {change: {}, revision: {}};
-      const hookStub = {onAttached: sinon.stub()};
-      sinon.stub(plugin, 'hook').returns(hookStub);
-      emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
-    });
-
-    test('plugin.panel is deprecated', () => {
-      plugin.panel('rubbish');
-      assert.isTrue(console.error.called);
-    });
-
-    [
-      ['CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', 'change-view-integration'],
-      ['CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK', 'change-metadata-item'],
-    ].forEach(([panelName, endpointName]) => {
-      test(`deprecated.panel works for ${panelName}`, () => {
-        const callback = sinon.stub();
-        plugin.deprecated.panel(panelName, callback);
-        assert.isTrue(plugin.hook.calledWith(endpointName));
-        emulateAttached();
-        assert.isTrue(callback.called);
-        const args = callback.args[0][0];
-        assert.strictEqual(args.body, fakeEl);
-        assert.strictEqual(args.p.CHANGE_INFO, fakeEl.change);
-        assert.strictEqual(args.p.REVISION_INFO, fakeEl.revision);
-      });
-    });
-  });
-
   suite('settingsScreen', () => {
-    test('plugin.settingsScreen is deprecated', () => {
-      plugin.settingsScreen('rubbish');
-      assert.isTrue(console.error.called);
-    });
-
     test('plugin.settings() returns GrSettingsApi', () => {
       assert.isOk(plugin.settings());
       assert.isTrue(plugin.settings() instanceof GrSettingsApi);
     });
-
-    test('plugin.deprecated.settingsScreen() works', () => {
-      const hookStub = {onAttached: sinon.stub()};
-      sinon.stub(plugin, 'hook').returns(hookStub);
-      const fakeSettings = {};
-      fakeSettings.title = sinon.stub().returns(fakeSettings);
-      fakeSettings.token = sinon.stub().returns(fakeSettings);
-      fakeSettings.module = sinon.stub().returns(fakeSettings);
-      fakeSettings.build = sinon.stub().returns(hookStub);
-      sinon.stub(plugin, 'settings').returns(fakeSettings);
-      const callback = sinon.stub();
-
-      plugin.deprecated.settingsScreen('path', 'menu', callback);
-      assert.isTrue(fakeSettings.title.calledWith('menu'));
-      assert.isTrue(fakeSettings.token.calledWith('path'));
-      assert.isTrue(fakeSettings.module.calledWith('div'));
-      assert.equal(fakeSettings.build.callCount, 1);
-
-      const fakeBody = {};
-      const fakeEl = {
-        style: {
-          display: '',
-        },
-        querySelector: sinon.stub().returns(fakeBody),
-      };
-      // Emulate settings screen attached
-      hookStub.onAttached.callArgWith(0, fakeEl);
-      assert.isTrue(callback.called);
-      const args = callback.args[0][0];
-      assert.strictEqual(args.body, fakeBody);
-      assert.equal(fakeEl.style.display, 'none');
-    });
   });
 });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 34ecd9e..4366dd5 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -42,7 +42,14 @@
   ) {}
 
   popup(element: Node) {
-    this._popups.push(this.plugin.deprecated.popup(element));
+    this.plugin.popup().then(popApi => {
+      const popupEl = popApi._getElement();
+      if (!popupEl) {
+        throw new Error('Popup element not found');
+      }
+      popupEl.appendChild(element);
+      this._popups.push(popApi);
+    });
   }
 
   hide() {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
index e93ffd2..27c9c28 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
@@ -33,17 +33,20 @@
     instance = new GrPluginActionContext(plugin);
   });
 
-  test('popup() and hide()', () => {
+  test('popup() and hide()', done => {
     const popupApiStub = {
+      _getElement: sinon.stub().returns(document.createElement('div')),
       close: sinon.stub(),
     };
-    sinon.stub(plugin.deprecated, 'popup').returns(popupApiStub);
-    const el = {};
+    sinon.stub(plugin, 'popup').returns(Promise.resolve(popupApiStub));
+    const el = document.createElement('span');
     instance.popup(el);
-    assert.isTrue(instance.plugin.deprecated.popup.calledWith(el));
-
-    instance.hide();
-    assert.isTrue(popupApiStub.close.called);
+    flush(() => {
+      assert.isTrue(popupApiStub._getElement.called);
+      instance.hide();
+      assert.isTrue(popupApiStub.close.called);
+      done();
+    });
   });
 
   test('textfield', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index c6b3485..043293f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -31,7 +31,6 @@
 import {GrRepoApi} from '../../plugins/gr-repo-api/gr-repo-api';
 import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api';
 import {GrStylesApi} from '../../plugins/gr-styles-api/gr-styles-api';
-import {GrPluginActionContext} from './gr-plugin-action-context';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
 
 import {PRELOADED_PROTOCOL, getPluginNameFromUrl, send} from './gr-api-utils';
@@ -39,23 +38,15 @@
 import {
   EventType,
   HookApi,
-  PanelInfo,
   PluginApi,
-  PluginDeprecatedApi,
   RegisterOptions,
-  SettingsInfo,
   TargetElement,
 } from '../../plugins/gr-plugin-types';
-import {ActionInfo, RequestPayload} from '../../../types/common';
+import {RequestPayload} from '../../../types/common';
 import {HttpMethod} from '../../../constants/constants';
 import {JsApiService} from './gr-js-api-types';
 import {GrChangeActions} from '../../../services/services/gr-rest-api/gr-rest-api';
 
-const PANEL_ENDPOINTS_MAPPING = {
-  CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK: 'change-view-integration',
-  CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
-};
-
 /**
  * Plugin-provided custom components can affect content in extension
  * points using one of following methods:
@@ -77,8 +68,6 @@
 export type SendCallback = (response: unknown) => void;
 
 export class Plugin implements PluginApi {
-  readonly deprecated: PluginDeprecatedApi;
-
   readonly _url?: URL;
 
   private _domHooks: GrDomHooksManager;
@@ -89,25 +78,6 @@
   private readonly sharedApiElement: JsApiService;
 
   constructor(url?: string) {
-    this.deprecated = {
-      _loadedGwt: () => {},
-      install: () => this.deprecatedInstall(),
-      onAction: (
-        type: string,
-        action: string,
-        callback: (ctx: GrPluginActionContext) => void
-      ) => this.deprecatedOnAction(type, action, callback),
-      panel: (extensionpoint: string, callback: (panel: PanelInfo) => void) =>
-        this.deprecatedPanel(extensionpoint, callback),
-      popup: (el: Element) => this.deprecatedPopup(el),
-      screen: (pattern: string, callback: (settings: SettingsInfo) => void) =>
-        this.deprecatedScreen(pattern, callback),
-      settingsScreen: (
-        path: string,
-        menu: string,
-        callback: (settings: SettingsInfo) => void
-      ) => this.deprecatedSettingsScreen(path, menu, callback),
-    };
     this.sharedApiElement = getSharedApiEl();
     this._domHooks = new GrDomHooksManager(this);
 
@@ -327,25 +297,16 @@
     return new GrEventHelper(element);
   }
 
-  popup(moduleName: string) {
-    if (typeof moduleName !== 'string') {
+  popup(): Promise<GrPopupInterface>;
+
+  popup(moduleName: string): Promise<GrPopupInterface>;
+
+  popup(moduleName?: string): Promise<GrPopupInterface | null> {
+    if (moduleName !== undefined && typeof moduleName !== 'string') {
       console.error('.popup(element) deprecated, use .popup(moduleName)!');
-      return;
+      return Promise.resolve(null);
     }
-    const api = new GrPopupInterface(this, moduleName);
-    return api.open();
-  }
-
-  panel() {
-    console.error(
-      '.panel() is deprecated! ' + 'Use registerCustomComponent() instead.'
-    );
-  }
-
-  settingsScreen() {
-    console.error(
-      '.settingsScreen() is deprecated! ' + 'Use .settings() instead.'
-    );
+    return new GrPopupInterface(this, moduleName).open();
   }
 
   screen(screenName: string, moduleName?: string) {
@@ -365,142 +326,4 @@
   _getScreenName(screenName: string) {
     return `${this.getPluginName()}-screen-${screenName}`;
   }
-
-  // !!! DEPRECATED !!!
-  // All methods below are deprecated!
-  // TODO: should be removed soon after all core plugins moved away from it.
-
-  deprecatedInstall() {
-    console.info('Installing deprecated APIs is deprecated!');
-    const deprecatedThis = (this as unknown) as PluginDeprecatedApi;
-    deprecatedThis._loadedGwt = this.deprecated._loadedGwt;
-    deprecatedThis.onAction = this.deprecated.onAction;
-    deprecatedThis.panel = this.deprecated.panel;
-    deprecatedThis.popup = this.deprecated.popup;
-    deprecatedThis.screen = this.deprecated.screen;
-    deprecatedThis.settingsScreen = this.deprecated.settingsScreen;
-  }
-
-  deprecatedPopup(el: Element): GrPopupInterface {
-    console.warn(
-      'plugin.deprecated.popup() is deprecated, ' + 'use plugin.popup() insted!'
-    );
-    if (!el) {
-      throw new Error('Popup contents not found');
-    }
-    const api = new GrPopupInterface(this);
-    api.open().then(api => {
-      const popupEl = api._getElement();
-      if (!popupEl) {
-        throw new Error('Popup element not found');
-      }
-      popupEl.appendChild(el);
-    });
-    return api;
-  }
-
-  deprecatedOnAction(
-    type: string,
-    action: string,
-    callback: (ctx: GrPluginActionContext) => void
-  ) {
-    console.warn(
-      'plugin.deprecated.onAction() is deprecated,' +
-        ' use plugin.changeActions() instead!'
-    );
-    if (type !== 'change' && type !== 'revision') {
-      console.warn(`${type} actions are not supported.`);
-      return;
-    }
-    this.on(EventType.SHOW_CHANGE, (change, revision) => {
-      const details: ActionInfo = this.changeActions().getActionDetails(action);
-      if (!details) {
-        console.warn(
-          `${this.getPluginName()} onAction error: ${action} not found!`
-        );
-        return;
-      }
-      if (!details.__key) {
-        console.warn(
-          `${this.getPluginName()} onAction error: ${action} has no key!`
-        );
-        return;
-      }
-      this.changeActions().addTapListener(details.__key, () => {
-        callback(new GrPluginActionContext(this, details, change, revision));
-      });
-    });
-  }
-
-  deprecatedScreen(
-    pattern: string,
-    callback: (settings: SettingsInfo) => void
-  ) {
-    console.warn(
-      'plugin.deprecated.screen is deprecated,' + ' use plugin.screen instead!'
-    );
-    this.hook(this._getScreenName(pattern)).onAttached(el => {
-      el.style.display = 'none';
-      callback({
-        body: el,
-        token: el.token,
-        onUnload: () => {},
-        setTitle: () => {},
-        setWindowTitle: () => {},
-        show: () => {
-          el.style.display = 'initial';
-        },
-      });
-    });
-  }
-
-  deprecatedSettingsScreen(
-    path: string,
-    menu: string,
-    callback: (settings: SettingsInfo) => void
-  ) {
-    console.warn('.settingsScreen() is deprecated! Use .settings() instead.');
-    const hook = this.settings().title(menu).token(path).module('div').build();
-    hook.onAttached(el => {
-      el.style.display = 'none';
-      const body = el.querySelector('div');
-      if (!body) return;
-      callback({
-        body,
-        onUnload: () => {},
-        setTitle: () => {},
-        setWindowTitle: () => {},
-        show: () => {
-          el.style.display = 'initial';
-        },
-      });
-    });
-  }
-
-  deprecatedPanel(
-    extensionpoint: string,
-    callback: (panel: PanelInfo) => void
-  ) {
-    console.warn(
-      '.panel() is deprecated! ' + 'Use registerCustomComponent() instead.'
-    );
-    let endpoint;
-    for (const [key, value] of Object.entries(PANEL_ENDPOINTS_MAPPING)) {
-      if (key === extensionpoint) endpoint = value;
-    }
-    if (!endpoint) {
-      console.warn(`.panel ${extensionpoint} not supported!`);
-      return;
-    }
-    this.hook(endpoint).onAttached(el =>
-      callback({
-        body: el,
-        p: {
-          CHANGE_INFO: el.change,
-          REVISION_INFO: el.revision,
-        },
-        onUnload: () => {},
-      })
-    );
-  }
 }