Merge "Fix PluginLogFile to not open multiple appenders when run in parallel"
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
index e4a927a..ccd50b7 100644
--- a/java/com/google/gerrit/server/comment/CommentContextKey.java
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -6,11 +6,10 @@
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
-import java.util.Collection;
 
 /**
  * An identifier of a comment that should be used to load the comment context using {@link
- * CommentContextCache#get(CommentContextKey)}, or {@link CommentContextCache#getAll(Collection)}.
+ * CommentContextCache#get(CommentContextKey)}, or {@link CommentContextCache#getAll(Iterable)}.
  *
  * <p>The {@link CommentContextCacheImpl} implementation uses this class as the cache key, while
  * replacing the {@link #path()} field with the hashed path.
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index b816264..e8f1fe1 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -138,8 +138,9 @@
     }
 
     /**
-     * Create change notes based on a {@link Change.Id}. This requires using the Change index and
-     * should only be used when {@link Project.NameKey} and the numeric change ID are not available.
+     * Create change notes based on a {@link com.google.gerrit.entities.Change.Id}. This requires
+     * using the Change index and should only be used when {@link
+     * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
     public ChangeNotes createCheckedUsingIndexLookup(Change.Id changeId) {
       InternalChangeQuery query = queryProvider.get().noFields();
@@ -155,9 +156,9 @@
     }
 
     /**
-     * Create change notes based on a list of {@link Change.Id}s. This requires using the Change
-     * index and should only be used when {@link Project.NameKey} and the numeric change ID are not
-     * available.
+     * Create change notes based on a list of {@link com.google.gerrit.entities.Change.Id}s. This
+     * requires using the Change index and should only be used when {@link
+     * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
     public List<ChangeNotes> createUsingIndexLookup(Collection<Change.Id> changeIds) {
       List<ChangeNotes> notes = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
index 9827a69..8f53751 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
@@ -56,7 +56,8 @@
 
   /**
    * Returns the old file path associated with the {@link FileHeader}, or empty if the file is
-   * {@link Patch.ChangeType#ADDED} or {@link Patch.ChangeType#REWRITE}.
+   * {@link com.google.gerrit.entities.Patch.ChangeType#ADDED} or {@link
+   * com.google.gerrit.entities.Patch.ChangeType#REWRITE}.
    */
   static Optional<String> getOldPath(FileHeader header) {
     Patch.ChangeType changeType = getChangeType(header);
@@ -76,7 +77,7 @@
 
   /**
    * Returns the new file path associated with the {@link FileHeader}, or empty if the file is
-   * {@link Patch.ChangeType#DELETED}.
+   * {@link com.google.gerrit.entities.Patch.ChangeType#DELETED}.
    */
   static Optional<String> getNewPath(FileHeader header) {
     Patch.ChangeType changeType = getChangeType(header);
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 41db9ee..66299a8 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -99,8 +99,8 @@
   }
 
   /**
-   * Returns the {@link Account.Id} of the current user if a user is signed in. Catches exceptions
-   * so that background jobs don't get impacted.
+   * Returns the {@link com.google.gerrit.entities.Account.Id} of the current user if a user is
+   * signed in. Catches exceptions so that background jobs don't get impacted.
    */
   private Optional<Account.Id> getAccountIdOfIdentifiedUser() {
     try {
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index cb34bdb..7f8add8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -18,6 +18,13 @@
 import static org.apache.http.HttpStatus.SC_CREATED;
 import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
 import static org.apache.http.HttpStatus.SC_OK;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
@@ -52,7 +59,6 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -370,37 +376,31 @@
 
   @Test
   public void performanceLoggingForRestCall() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       RestResponse response = adminRestSession.put("/projects/new10");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-
-      // This assertion assumes that the server invokes the PerformanceLogger plugins before it
-      // sends
-      // the response to the client. If this assertion gets flaky it's likely that this got changed
-      // on
-      // server-side.
-      assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
     }
   }
 
   @Test
   public void performanceLoggingForPush() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
-      assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
     }
   }
 
   @Test
   @GerritConfig(name = "tracing.performanceLogging", value = "false")
   public void noPerformanceLoggingIfDisabled() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       RestResponse response = adminRestSession.put("/projects/new11");
@@ -410,7 +410,7 @@
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
 
-      assertThat(testPerformanceLogger.logEntries()).isEmpty();
+      verifyZeroInteractions(testPerformanceLogger);
     }
   }
 
@@ -844,19 +844,6 @@
     }
   }
 
-  private static class TestPerformanceLogger implements PerformanceLogger {
-    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
-
-    @Override
-    public void log(String operation, long durationMs, Metadata metadata) {
-      logEntries.add(PerformanceLogEntry.create(operation, metadata));
-    }
-
-    ImmutableList<PerformanceLogEntry> logEntries() {
-      return ImmutableList.copyOf(logEntries);
-    }
-  }
-
   @AutoValue
   abstract static class PerformanceLogEntry {
     static PerformanceLogEntry create(String operation, Metadata metadata) {
diff --git a/plugins/gitiles b/plugins/gitiles
index a33c8b8..0cd08bb 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit a33c8b8d61b778f8ca84196b1a7cc1fd4fe24946
+Subproject commit 0cd08bbfac1a938079be8aaf6a6eef0e82ff9a41
diff --git a/plugins/replication b/plugins/replication
index 4efef1d..9c21a2f 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 4efef1d481eefaee287b27488be94f9acb044360
+Subproject commit 9c21a2f5cc539b5a13d2646edc15bd17d7977fb3
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index c683a32..df52323 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -149,7 +149,6 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
-import {PORTING_COMMENTS_CHANGE_LATENCY_LABEL} from '../../../services/gr-reporting/gr-reporting';
 import {fireAlert, fireEvent, firePageError} from '../../../utils/event-util';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {fireTitleChange} from '../../../utils/event-util';
@@ -2080,19 +2079,11 @@
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
 
-    const portedCommentsPromise = this.$.commentAPI.getPortedComments(
-      this._changeNum
-    );
-    const commentsPromise = this.$.commentAPI
-      .loadAll(this._changeNum)
+    return this.$.commentAPI
+      .loadAll(this._changeNum, this._patchRange?.patchNum)
       .then(comments => {
-        this.reporting.time(PORTING_COMMENTS_CHANGE_LATENCY_LABEL);
         this._recomputeComments(comments);
       });
-    Promise.all([portedCommentsPromise, commentsPromise]).then(() => {
-      this.reporting.timeEnd(PORTING_COMMENTS_CHANGE_LATENCY_LABEL);
-    });
-    return commentsPromise;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 8fce39a..fb995b7 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -72,8 +72,9 @@
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {PatchSetFile} from '../../../types/types';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {PatchSetFile} from '../../../types/types';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -321,10 +322,14 @@
   @property({type: Array})
   _dynamicPrependedContentEndpoints?: string[];
 
+  private _isPortingCommentsExperimentEnabled = false;
+
   private readonly reporting = appContext.reportingService;
 
   private readonly restApiService = appContext.restApiService;
 
+  private readonly flagsService = appContext.flagsService;
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -367,6 +372,9 @@
   /** @override */
   attached() {
     super.attached();
+    this._isPortingCommentsExperimentEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.PORTING_COMMENTS
+    );
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -1544,9 +1552,11 @@
             'changeComments, patchRange and diffPrefs must be set'
           );
         }
+
         diffElem.threads = this.changeComments.getThreadsBySideForFile(
           file,
-          this.patchRange
+          this.patchRange,
+          this._isPortingCommentsExperimentEnabled
         );
         const promises: Array<Promise<unknown>> = [diffElem.reload()];
         if (this._loggedIn && !this.diffPrefs.manual_review) {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index e62d481..2c21b4a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -127,6 +127,7 @@
       </template>
       <gr-comment-thread
         show-file-path=""
+        show-ported-comment="[[thread.ported]]"
         change-num="[[changeNum]]"
         comments="[[thread.comments]]"
         diff-side="[[thread.diffSide]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 1f84680..d5f1420 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -28,7 +28,7 @@
   RobotCommentInfo,
   UrlEncodedCommentId,
   NumericChangeId,
-  RevisionId,
+  PathToCommentsInfoMap,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
@@ -43,9 +43,13 @@
   UIRobot,
   createCommentThreads,
   isInPatchRange,
+  isDraftThread,
+  isInBaseOfPatchRange,
+  isInRevisionOfPatchRange,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
 import {appContext} from '../../../services/app-context';
+import {CommentSide, Side} from '../../../constants/constants';
 
 export type CommentIdToCommentThreadMap = {
   [urlEncodedCommentId: string]: CommentThread;
@@ -58,6 +62,8 @@
 
   private readonly _drafts: {[path: string]: UIDraft[]};
 
+  private readonly _portedComments: PathToCommentsInfoMap;
+
   /**
    * Construct a change comments object, which can be data-bound to child
    * elements of that which uses the gr-comment-api.
@@ -65,11 +71,13 @@
   constructor(
     comments: {[path: string]: UIHuman[]} | undefined,
     robotComments: {[path: string]: UIRobot[]} | undefined,
-    drafts: {[path: string]: UIDraft[]} | undefined
+    drafts: {[path: string]: UIDraft[]} | undefined,
+    portedComments: PathToCommentsInfoMap | undefined
   ) {
     this._comments = this._addPath(comments);
     this._robotComments = this._addPath(robotComments);
     this._drafts = this._addPath(drafts);
+    this._portedComments = portedComments || {};
   }
 
   /**
@@ -94,18 +102,10 @@
     return updatedComments;
   }
 
-  get comments() {
-    return this._comments;
-  }
-
   get drafts() {
     return this._drafts;
   }
 
-  get robotComments() {
-    return this._robotComments;
-  }
-
   findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
     if (!commentId) return undefined;
     const findComment = (comments: {[path: string]: UIComment[]}) => {
@@ -135,9 +135,9 @@
    */
   getPaths(patchRange?: PatchRange): CommentMap {
     const responses: {[path: string]: UIComment[]}[] = [
-      this.comments,
+      this._comments,
       this.drafts,
-      this.robotComments,
+      this._robotComments,
     ];
     const commentMap: CommentMap = {};
     for (const response of responses) {
@@ -258,6 +258,15 @@
     return allComments;
   }
 
+  cloneWithUpdatedDrafts(drafts: {[path: string]: UIDraft[]} | undefined) {
+    return new ChangeComments(
+      this._comments,
+      this._robotComments,
+      drafts,
+      this._portedComments
+    );
+  }
+
   /**
    * Get the drafts for a path and optional patch num.
    *
@@ -289,16 +298,6 @@
     return allDrafts;
   }
 
-  getThreadsBySideForPath(
-    path: string,
-    patchRange: PatchRange
-  ): CommentThread[] {
-    return createCommentThreads(
-      this.getCommentsForPath(path, patchRange),
-      patchRange
-    );
-  }
-
   /**
    * Get the comments (with drafts and robot comments) for a path and
    * patch-range. Returns an object with left and right properties mapping to
@@ -313,14 +312,14 @@
     let comments: Comment[] = [];
     let drafts: DraftInfo[] = [];
     let robotComments: RobotCommentInfo[] = [];
-    if (this.comments && this.comments[path]) {
-      comments = this.comments[path];
+    if (this._comments && this._comments[path]) {
+      comments = this._comments[path];
     }
     if (this.drafts && this.drafts[path]) {
       drafts = this.drafts[path];
     }
-    if (this.robotComments && this.robotComments[path]) {
-      robotComments = this.robotComments[path];
+    if (this._robotComments && this._robotComments[path]) {
+      robotComments = this._robotComments[path];
     }
 
     drafts.forEach(d => {
@@ -336,14 +335,94 @@
       });
   }
 
-  getThreadsBySideForFile(
+  /**
+   * Get the ported threads for given patch range.
+   * Ported threads are comment threads that were posted on an older patchset
+   * and are displayed on a later patchset.
+   * It is simply the original thread displayed on a newer patchset.
+   *
+   * Threads are ported over to all subsequent patchsets. So, a thread created
+   * on patchset 5 say will be ported over to patchsets 6,7,8 and beyond.
+   *
+   * Ported threads add a boolean property ported true to the thread object
+   * to indicate to the user that this is a ported thread.
+   *
+   * Any interactions with ported threads are reflected on the original threads.
+   * Replying to a ported thread ported from Patchset 6 shown on Patchset 10
+   * say creates a draft reply associated with Patchset 6, since the user is
+   * interacting with the original thread.
+   *
+   * Only threads with unresolved comments or drafts are ported over.
+   * If the thread is associated with either the left patchset or the right
+   * patchset, then we filter that ported thread from the return value
+   * as it will be rendered by default.
+   *
+   * If there is no appropriate range for the ported comments, then the backend
+   * does not return the range of the ported thread and it becomes a file level
+   * thread.
+   *
+   * @return only the ported threads for the specified file and patch range
+   */
+  _getPortedCommentThreads(
     file: PatchSetFile,
     patchRange: PatchRange
   ): CommentThread[] {
-    return createCommentThreads(
+    const portedComments = this._portedComments[file.path];
+    if (file.basePath)
+      portedComments.push(...this._portedComments[file.basePath]);
+    if (!portedComments) return [];
+
+    // when forming threads in diff view, we filter for current patchrange but
+    // ported comments will involve comments that may not belong to the
+    // current patchrange, so we need to form threads for them using all
+    // comments
+    const allComments: UIComment[] = this.getAllCommentsForFile(file, true);
+
+    return createCommentThreads(allComments).filter(thread => {
+      // Robot comments and drafts are not ported over. A human reply to
+      // the robot comment will be ported over, thefore it's possible to
+      // have the root comment of the thread not be ported, hence loop over
+      // entire thread
+      const portedComment = portedComments.find(portedComment =>
+        thread.comments.some(c => portedComment.id === c.id)
+      );
+      if (!portedComment) return false;
+
+      if (
+        isInBaseOfPatchRange(thread.comments[0], patchRange) ||
+        isInRevisionOfPatchRange(thread.comments[0], patchRange)
+      ) {
+        // no need to port this thread as it will be rendered by default
+        return false;
+      }
+
+      // TODO(dhruvsri): Add handling for thread.commentSide = PARENT
+      if (thread.commentSide === CommentSide.PARENT) return false;
+
+      if (!isUnresolved(thread) && !isDraftThread(thread)) return false;
+
+      thread.range = portedComment.range;
+      thread.line = portedComment.line;
+      thread.ported = true;
+      thread.diffSide = Side.RIGHT;
+      return true;
+    });
+  }
+
+  getThreadsBySideForFile(
+    file: PatchSetFile,
+    patchRange: PatchRange,
+    includePortedComments?: boolean
+  ): CommentThread[] {
+    const threads = createCommentThreads(
       this.getCommentsForFile(file, patchRange),
       patchRange
     );
+
+    if (includePortedComments) {
+      threads.push(...this._getPortedCommentThreads(file, patchRange));
+    }
+    return threads;
   }
 
   /**
@@ -451,41 +530,36 @@
     super.created();
   }
 
-  getPortedComments(changeNum: NumericChangeId, revision?: RevisionId) {
-    if (!revision) revision = CURRENT;
-    return Promise.all([
-      this.restApiService.getPortedComments(changeNum, revision),
-      this.restApiService.getPortedDrafts(changeNum, revision),
-    ]).then(result => {
-      return {
-        portedComments: result[0],
-        portedDrafts: result[1],
-      };
-    });
-  }
-
   /**
    * Load all comments (with drafts and robot comments) for the given change
    * number. The returned promise resolves when the comments have loaded, but
    * does not yield the comment data.
    */
-  loadAll(changeNum: NumericChangeId) {
-    const promises = [];
-    promises.push(this.restApiService.getDiffComments(changeNum));
-    promises.push(this.restApiService.getDiffRobotComments(changeNum));
-    promises.push(this.restApiService.getDiffDrafts(changeNum));
+  loadAll(changeNum: NumericChangeId, patchNum?: PatchSetNum) {
+    const revision = patchNum || CURRENT;
+    const commentsPromise: [
+      Promise<PathToCommentsInfoMap | undefined>,
+      Promise<PathToRobotCommentsInfoMap | undefined>,
+      Promise<PathToCommentsInfoMap | undefined>,
+      Promise<PathToCommentsInfoMap | undefined>
+    ] = [
+      this.restApiService.getDiffComments(changeNum),
+      this.restApiService.getDiffRobotComments(changeNum),
+      this.restApiService.getDiffDrafts(changeNum),
+      this.restApiService.getPortedComments(changeNum, revision),
+    ];
 
-    return Promise.all(promises).then(([comments, robotComments, drafts]) => {
-      this._changeComments = new ChangeComments(
-        comments,
-        // TODO(TS): Promise.all somehow resolve all types to
-        // PathToCommentsInfoMap given its PathToRobotCommentsInfoMap
-        // returned from the second promise
-        robotComments as PathToRobotCommentsInfoMap,
-        drafts
-      );
-      return this._changeComments;
-    });
+    return Promise.all(commentsPromise).then(
+      ([comments, robotComments, drafts, portedComments]) => {
+        this._changeComments = new ChangeComments(
+          comments,
+          robotComments,
+          drafts,
+          portedComments
+        );
+        return this._changeComments;
+      }
+    );
   }
 
   /**
@@ -497,11 +571,8 @@
     if (!this._changeComments) {
       return this.loadAll(changeNum);
     }
-    const oldChangeComments = this._changeComments;
     return this.restApiService.getDiffDrafts(changeNum).then(drafts => {
-      this._changeComments = new ChangeComments(
-        oldChangeComments.comments,
-        (oldChangeComments.robotComments as unknown) as PathToRobotCommentsInfoMap,
+      this._changeComments = this._changeComments!.cloneWithUpdatedDrafts(
         drafts
       );
       return this._changeComments;
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index 6b6756e..f724f41 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -19,7 +19,7 @@
 import './gr-comment-api.js';
 import {ChangeComments} from './gr-comment-api.js';
 import {CommentSide} from '../../../constants/constants.js';
-import {isInRevisionOfPatchRange, isInBaseOfPatchRange} from '../../../utils/comment-util.js';
+import {isInRevisionOfPatchRange, isInBaseOfPatchRange, createCommentThreads} from '../../../utils/comment-util.js';
 
 const basicFixture = fixtureFromElement('gr-comment-api');
 
@@ -147,7 +147,142 @@
       });
     });
 
-    test('isInBaseOfPatchRange', () => {
+    suite('ported comments', () => {
+      let portedComments;
+      let changeComments;
+      setup(() => {
+        portedComments = {
+          'karma.conf.js': [
+            {
+              unresolved: true,
+              patch_set: 4,
+              id: 'db977012_e1f13818',
+              line: 136,
+              range: {
+                start_line: 136,
+                start_character: 16,
+                end_line: 136,
+                end_character: 29,
+              },
+              updated: '2020-11-19 12:06:48.000000000',
+            },
+          ],
+        };
+
+        changeComments = new ChangeComments(
+            {
+              'karma.conf.js': [
+                {
+                  // resolved comment that will not be ported over
+                  patch_set: 2,
+                  id: 'ecf0b9fa_fe1a5f62',
+                  line: 5,
+                  updated: '2018-02-08 18:49:18.000000000',
+                  unresolved: false,
+                },
+                {
+                  unresolved: true,
+                  // original comment that will be ported over to patchset 4
+                  patch_set: 2,
+                  id: 'db977012_e1f13818',
+                  line: 136,
+                  range: {
+                    start_line: 1,
+                    start_character: 1,
+                    end_line: 1,
+                    end_character: 1,
+                  },
+                  updated: '2020-11-19 12:06:48.000000000',
+                },
+              ],
+            },
+            {},
+            {},
+            portedComments
+        );
+      });
+
+      test('threads containing ported comment are returned', () => {
+        assert.equal(changeComments.getAllThreadsForChange().length,
+            2);
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
+
+        assert.equal(portedThreads.length, 1);
+        // check range of thread is from the ported comment and not the original
+        assert.deepEqual(portedThreads[0].range, {
+          start_line: 136,
+          start_character: 16,
+          end_line: 136,
+          end_character: 29,
+        });
+
+        // thread ported over if comparing patchset 1 vs patchset 4
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 1}
+        ).length, 1);
+
+        // verify ported thread is not returned if original thread will be
+        // shown
+        // original thread attached to right side
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 2, basePatchNum: 'PARENT'}
+        ).length, 0);
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 2, basePatchNum: 1}
+        ).length, 0);
+
+        // original thread attached to left side
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 3, basePatchNum: 2}
+        ).length, 0);
+      });
+
+      test('threads without any ported comment are filtered out', () => {
+        changeComments = new ChangeComments(
+            {
+              // comment that is not ported over
+              'karma.conf.js': [
+                {
+                  patch_set: 2,
+                  id: 'ecf0b9fa_fe1a5f62',
+                  line: 5,
+                  updated: '2018-02-08 18:49:18.000000000',
+                  unresolved: false,
+                },
+              ],
+            },
+            {},
+            {
+              'karma.conf.js': [
+                // comment that is ported over but does not have any thread
+                // that has a comment that matches it
+                {
+                  id: '503008e2_0ab203ee',
+                  path: 'karma.conf.js',
+                  line: 5,
+                  in_reply_to: 'ecf0b9fa_fe1a5f62',
+                  updated: '2018-02-13 22:48:48.018000000',
+                  unresolved: true,
+                  __draft: true,
+                  __draftID: '0.m683trwff68',
+                  patch_set: 2,
+                },
+              ],
+            },
+            portedComments
+        );
+
+        assert.equal(createCommentThreads(changeComments
+            .getAllCommentsForPath('karma.conf.js')).length, 1);
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'}
+        ).length, 0);
+      });
+    });
+
+    test('_isInBaseOfPatchRange', () => {
       const comment = {patch_set: 1};
       const patchRange = {basePatchNum: 1, patchNum: 2};
       assert.isTrue(isInBaseOfPatchRange(comment,
@@ -296,7 +431,7 @@
           ],
         };
         element._changeComments =
-            new ChangeComments(comments, robotComments, drafts, 1234);
+            new ChangeComments(comments, robotComments, drafts, {}, 1234);
       });
 
       test('getPaths', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 32c9964..d084457 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -779,6 +779,7 @@
     threadEl.changeNum = this.changeNum;
     threadEl.patchNum = thread.patchNum;
     threadEl.showPatchset = false;
+    threadEl.showPortedComment = !!thread.ported;
     // GrCommentThread does not understand 'FILE', but requires undefined.
     threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
     threadEl.projectName = this.projectName;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 9bde122..2c050f9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -24,6 +24,7 @@
 import {Side, CommentSide} from '../../../constants/constants.js';
 import {createChange} from '../../../test/test-data-generators.js';
 import {FILE} from '../gr-diff/gr-diff-line.js';
+import {CoverageType} from '../../../types/types.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
 
@@ -1003,14 +1004,14 @@
 
     assert.equal(actualThreads.length, 2);
 
-    assert.equal(actualThreads[0].diffSide, 'left');
+    assert.equal(actualThreads[0].diffSide, Side.LEFT);
     assert.equal(actualThreads[0].comments.length, 2);
     assert.deepEqual(actualThreads[0].comments[0], comments[0]);
     assert.deepEqual(actualThreads[0].comments[1], comments[1]);
     assert.equal(actualThreads[0].patchNum, 1);
     assert.equal(actualThreads[0].line, 1);
 
-    assert.equal(actualThreads[1].diffSide, 'left');
+    assert.equal(actualThreads[1].diffSide, Side.LEFT);
     assert.equal(actualThreads[1].comments.length, 1);
     assert.deepEqual(actualThreads[1].comments[0], comments[2]);
     assert.equal(actualThreads[1].patchNum, 1);
@@ -1035,7 +1036,7 @@
 
     const expectedThreads = [
       {
-        diffSide: 'left',
+        diffSide: Side.LEFT,
         commentSide: CommentSide.REVISION,
         path: '/p',
         rootId: 'betsys_confession',
@@ -1090,7 +1091,7 @@
       });
 
   test('_getOrCreateThread', () => {
-    const diffSide = 'left';
+    const diffSide = Side.LEFT;
     const commentSide = CommentSide.PARENT;
 
     assert.isOk(element._getOrCreateThread('2', 3,
@@ -1126,7 +1127,7 @@
 
   test('thread should use old file path if first created ' +
    'on patch set (left) before renaming', () => {
-    const diffSide = 'left';
+    const diffSide = Side.LEFT;
     element.file = {basePath: 'file_renamed.txt', path: element.path};
 
     assert.isOk(element._getOrCreateThread('2', 3,
@@ -1142,7 +1143,7 @@
 
   test('thread should use new file path if first created' +
    'on patch set (right) after renaming', () => {
-    const diffSide = 'right';
+    const diffSide = Side.RIGHT;
     element.file = {basePath: 'file_renamed.txt', path: element.path};
 
     assert.isOk(element._getOrCreateThread('2', 3,
@@ -1158,7 +1159,7 @@
 
   test('thread should use new file path if first created' +
    'on patch set (left) but is base', () => {
-    const diffSide = 'left';
+    const diffSide = Side.LEFT;
     element.file = {basePath: 'file_renamed.txt', path: element.path};
 
     assert.isOk(element._getOrCreateThread('2', 3,
@@ -1188,19 +1189,19 @@
 
     const l3 = document.createElement('div');
     l3.setAttribute('line-num', 3);
-    l3.setAttribute('diff-side', 'left');
+    l3.setAttribute('diff-side', Side.LEFT);
 
     const l5 = document.createElement('div');
     l5.setAttribute('line-num', 5);
-    l5.setAttribute('diff-side', 'left');
+    l5.setAttribute('diff-side', Side.LEFT);
 
     const r3 = document.createElement('div');
     r3.setAttribute('line-num', 3);
-    r3.setAttribute('diff-side', 'right');
+    r3.setAttribute('diff-side', Side.RIGHT);
 
     const r5 = document.createElement('div');
     r5.setAttribute('line-num', 5);
-    r5.setAttribute('diff-side', 'right');
+    r5.setAttribute('diff-side', Side.RIGHT);
 
     const threadEls = [l3, l5, r3, r5];
     assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
@@ -1213,11 +1214,11 @@
     const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
 
     const l = document.createElement('div');
-    l.setAttribute('diff-side', 'left');
+    l.setAttribute('diff-side', Side.LEFT);
     l.setAttribute('line-num', 'FILE');
 
     const r = document.createElement('div');
-    r.setAttribute('diff-side', 'right');
+    r.setAttribute('diff-side', Side.RIGHT);
     r.setAttribute('line-num', 'FILE');
 
     const threadEls = [l, r];
@@ -1320,31 +1321,37 @@
 
   suite('coverage layer', () => {
     let notifyStub;
+    let coverageProviderStub;
+    const exampleRanges = [
+      {
+        type: CoverageType.COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 1,
+          end_line: 2,
+        },
+      },
+      {
+        type: CoverageType.NOT_COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 3,
+          end_line: 4,
+        },
+      },
+    ];
+
     setup(() => {
       notifyStub = sinon.stub();
+      coverageProviderStub = sinon.stub().returns(
+          Promise.resolve(exampleRanges));
+
       stub('gr-js-api-interface', {
         getCoverageAnnotationApis() {
           return Promise.resolve([{
             notify: notifyStub,
             getCoverageProvider() {
-              return () => Promise.resolve([
-                {
-                  type: 'COVERED',
-                  side: 'right',
-                  code_range: {
-                    start_line: 1,
-                    end_line: 2,
-                  },
-                },
-                {
-                  type: 'NOT_COVERED',
-                  side: 'right',
-                  code_range: {
-                    start_line: 3,
-                    end_line: 4,
-                  },
-                },
-              ]);
+              return coverageProviderStub;
             },
           }]);
         },
@@ -1380,9 +1387,38 @@
       element.reload();
       flush(() => {
         assert.equal(notifyStub.callCount, 2);
+        assert.isTrue(notifyStub.calledWithExactly(
+            'some/path', 1, 2, Side.RIGHT));
+        assert.isTrue(notifyStub.calledWithExactly(
+            'some/path', 3, 4, Side.RIGHT));
         done();
       });
     });
+
+    test('provider is called with appropriate params', done => {
+      element.patchRange.basePatchNum = 1;
+      element.patchRange.patchNum = 3;
+
+      element.reload();
+      flush(() => {
+        assert.isTrue(coverageProviderStub.calledWithExactly(
+            123, 'some/path', 1, 3, element.change));
+        done();
+      });
+    });
+
+    test('provider is called with appropriate params - special patchset values',
+        done => {
+          element.patchRange.basePatchNum = 'PARENT';
+          element.patchRange.patchNum = 'invalid';
+
+          element.reload();
+          flush(() => {
+            assert.isTrue(coverageProviderStub.calledWithExactly(
+                123, 'some/path', undefined, undefined, element.change));
+            done();
+          });
+        });
   });
 
   suite('trailing newlines', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index a6f4717..853d3c6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -47,7 +47,6 @@
   computeLatestPatchNum,
   patchNumEquals,
   PatchSet,
-  CURRENT,
 } from '../../../utils/patch-set-util';
 import {
   addUnmodifiedFiles,
@@ -95,9 +94,9 @@
 import {CommentMap, isInBaseOfPatchRange} from '../../../utils/comment-util';
 import {AppElementParams} from '../../gr-app-types';
 import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
-import {PORTING_COMMENTS_DIFF_LATENCY_LABEL} from '../../../services/gr-reporting/gr-reporting';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 
+import {KnownExperimentId} from '../../../services/flags/flags';
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
 const MSG_LOADED_BLAME = 'Blame loaded';
@@ -264,6 +263,8 @@
   @property({type: Number})
   _focusLineNum?: number;
 
+  private _isPortingCommentsExperimentEnabled = false;
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -343,6 +344,9 @@
       this.$.cursor.reInitCursor();
     };
     this.$.diffHost.addEventListener('render', this._onRenderHandler);
+    this._isPortingCommentsExperimentEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.PORTING_COMMENTS
+    );
   }
 
   /** @override */
@@ -1047,11 +1051,6 @@
       return;
     }
 
-    const portedCommentsPromise = this.$.commentAPI.getPortedComments(
-      value.changeNum,
-      value.patchNum || CURRENT
-    );
-
     const promises: Promise<unknown>[] = [];
 
     promises.push(this._getDiffPreferences());
@@ -1063,7 +1062,7 @@
     );
 
     promises.push(this._getChangeDetail(this._changeNum));
-    promises.push(this._loadComments());
+    promises.push(this._loadComments(value.patchNum));
 
     promises.push(this._getChangeEdit());
 
@@ -1072,19 +1071,22 @@
     this._loading = true;
     return Promise.all(promises)
       .then(r => {
-        this.reporting.time(PORTING_COMMENTS_DIFF_LATENCY_LABEL);
         this._loading = false;
         this._initPatchRange();
         this._initCommitRange();
-        if (this._changeComments && this._path && this._patchRange) {
-          this.$.diffHost.threads = this._changeComments.getThreadsBySideForPath(
-            this._path,
-            this._patchRange
-          );
-        }
-        portedCommentsPromise.then(() => {
-          this.reporting.timeEnd(PORTING_COMMENTS_DIFF_LATENCY_LABEL);
-        });
+
+        if (!this._path) throw new Error('path must be defined');
+        if (!this._changeComments)
+          throw new Error('change comments must be defined');
+        if (!this._patchRange) throw new Error('patch range must be defined');
+
+        // TODO(dhruvsri): check if basePath should be set here
+        this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
+          {path: this._path},
+          this._patchRange,
+          this._isPortingCommentsExperimentEnabled
+        );
+
         const edit = r[4] as EditInfo | undefined;
         if (edit) {
           this.set(`_change.revisions.${edit.commit.commit}`, {
@@ -1542,11 +1544,13 @@
     return url;
   }
 
-  _loadComments() {
+  _loadComments(patchSet?: PatchSetNum) {
     if (!this._changeNum) throw new Error('Missing this._changeNum');
-    return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
-      this._changeComments = comments;
-    });
+    return this.$.commentAPI
+      .loadAll(this._changeNum, patchSet)
+      .then(comments => {
+        this._changeComments = comments;
+      });
   }
 
   @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index a8d3abd..1cb22e8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -23,7 +23,6 @@
 import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {_testOnly_findCommentById} from '../gr-comment-api/gr-comment-api.js';
-import {appContext} from '../../../services/app-context.js';
 import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
 import {
   createChange,
@@ -90,7 +89,6 @@
 
     setup(async () => {
       clock = sinon.useFakeTimers();
-      sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
       stub('gr-rest-api-interface', {
         getConfig() {
           return Promise.resolve({change: {}});
@@ -119,6 +117,9 @@
         getDiffDrafts() {
           return Promise.resolve({});
         },
+        getPortedComments() {
+          return Promise.resolve({});
+        },
         getReviewedFiles() {
           return Promise.resolve([]);
         },
@@ -151,8 +152,7 @@
         computeCommentThreadCount: () => {},
         computeUnresolvedNum: () => {},
         getPaths: () => {},
-        getThreadsBySideForPath: () => {},
-        getThreadsBySideForFile: () => {},
+        getThreadsBySideForFile: () => [],
         findCommentById: _testOnly_findCommentById,
 
       }));
@@ -178,6 +178,8 @@
         basePatchNum: 1,
         path: '/COMMIT_MSG',
       };
+      element._path = '/COMMIT_MSG';
+      element._patchRange = {};
       return element._paramsChanged.returnValues[0].then(() => {
         assert.isTrue(element.reporting.diffViewDisplayed.calledOnce);
       });
@@ -235,6 +237,8 @@
         basePatchNum: 1,
         path: '/COMMIT_MSG',
       };
+      element._path = '/COMMIT_MSG';
+      element._patchRange = {};
       return element._paramsChanged.returnValues[0].then(() => {
         assert.isTrue(element._isBlameLoaded);
         assert.isTrue(element._loadBlame.calledOnce);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
index eebcb15..038153a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -83,7 +83,7 @@
 // For Gerrit these are instances of GrCommentThread, but other gr-diff users
 // have different HTML elements in use for comment threads.
 // TODO: Also document the required HTML attritbutes that thread elements must
-// have, e.g. 'comment-side', 'range', 'line-num', 'data-value'.
+// have, e.g. 'diff-side', 'range', 'line-num', 'data-value'.
 export interface GrDiffThreadElement extends HTMLElement {
   rootId: string;
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index 2c8b704..557d1eb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -552,9 +552,9 @@
 
     /** Make comments selectable when selected */
     .selected-left.selected-comment
-      ::slotted(gr-comment-thread[comment-side='left']),
+      ::slotted(gr-comment-thread[diff-side='left']),
     .selected-right.selected-comment
-      ::slotted(gr-comment-thread[comment-side='right']) {
+      ::slotted(gr-comment-thread[diff-side='right']) {
       -webkit-user-select: text;
       -moz-user-select: text;
       -ms-user-select: text;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 0e0ee0c..1124cae 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -172,6 +172,9 @@
   showFileName = true;
 
   @property({type: Boolean})
+  showPortedComment = false;
+
+  @property({type: Boolean})
   showPatchset = true;
 
   get keyBindings() {
@@ -287,6 +290,11 @@
     return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
   }
 
+  _computeShowPortedComment(comment: UIComment) {
+    if (this._orderedComments.length === 0) return false;
+    return this.showPortedComment && comment.id === this._orderedComments[0].id;
+  }
+
   _computeDisplayPath(path: string) {
     const displayPath = computeDisplayPath(path);
     if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
index 64be155..f5d794f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -113,10 +113,12 @@
         comments="{{comments}}"
         robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
         change-num="[[changeNum]]"
+        project-name="[[projectName]]"
         patch-num="[[patchNum]]"
         draft="[[_isDraft(comment)]]"
         show-actions="[[_showActions]]"
         show-patchset="[[showPatchset]]"
+        show-ported-comment="[[_computeShowPortedComment(comment)]]"
         side="[[comment.side]]"
         project-config="[[_projectConfig]]"
         on-create-fix-comment="_handleCommentFix"
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index b2dcb88..3dd851e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -38,6 +38,7 @@
 import {getRootElement} from '../../../scripts/rootElement';
 import {appContext} from '../../../services/app-context';
 import {customElement, observe, property} from '@polymer/decorators';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
 import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
 import {GrOverlay} from '../gr-overlay/gr-overlay';
@@ -46,6 +47,7 @@
   NumericChangeId,
   ConfigInfo,
   PatchSetNum,
+  RepoName,
 } from '../../../types/common';
 import {GrButton} from '../gr-button/gr-button';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
@@ -152,6 +154,9 @@
   @property({type: Number})
   changeNum?: NumericChangeId;
 
+  @property({type: String})
+  projectName?: RepoName;
+
   @property({type: Object, notify: true, observer: '_commentChanged'})
   comment?: UIComment;
 
@@ -254,6 +259,9 @@
   @property({type: Object})
   _selfAccount?: AccountDetailInfo;
 
+  @property({type: Boolean})
+  showPortedComment = false;
+
   get keyBindings() {
     return {
       'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
@@ -294,6 +302,16 @@
     return comment.author || this._selfAccount;
   }
 
+  _getUrlForComment(comment: UIComment) {
+    if (!this.changeNum || !this.projectName) return '';
+    if (!comment.id) throw new Error('comment must have an id');
+    return GerritNav.getUrlForComment(
+      this.changeNum as NumericChangeId,
+      this.projectName,
+      comment.id
+    );
+  }
+
   @observe('editing')
   _onEditingChange(editing?: boolean) {
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index 64f0be1..6bf00a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -260,6 +260,11 @@
           >
           </gr-account-label>
         </template>
+        <template is="dom-if" if="[[showPortedComment]]">
+          <a href="[[_getUrlForComment(comment)]]">
+            Ported from patchset [[comment.patch_set]]
+          </a>
+        </template>
         <gr-tooltip-content
           class="draftTooltip"
           has-tooltip=""
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
index cd701fc..68a73933 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -20,7 +20,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-overlay_html';
 import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
-import {customElement, property} from '@polymer/decorators';
+import {customElement} from '@polymer/decorators';
 import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
 import {findActiveElement} from '../../../utils/dom-util';
 import {fireEvent} from '../../../utils/event-util';
@@ -56,7 +56,6 @@
    * @event fullscreen-overlay-opened
    */
 
-  @property({type: Boolean})
   private _fullScreenOpen = false;
 
   private _boundHandleClose: () => void = () => super.close();
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index ba33954..36fe858 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -28,4 +28,5 @@
   NEW_CONTEXT_CONTROLS = 'UiFeature__new_context_controls',
   CI_REBOOT_CHECKS = 'UiFeature__ci_reboot_checks',
   NEW_CHANGE_SUMMARY_UI = 'UiFeature__new_change_summary_ui',
+  PORTING_COMMENTS = 'UiFeature__porting_comments',
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 48c90f7..090c42f 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -21,10 +21,6 @@
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventDetails = any;
 
-export const PORTING_COMMENTS_DIFF_LATENCY_LABEL = 'PortingCommentsDiffLatency';
-export const PORTING_COMMENTS_CHANGE_LATENCY_LABEL =
-  'PortingCommentsChangeLatency';
-
 export interface Timer {
   reset(): this;
   end(): this;
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index b454b6e..1628134 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1175,11 +1175,6 @@
 
 export type PathToCommentsInfoMap = {[path: string]: CommentInfo[]};
 
-export type PortedCommentsAndDrafts = {
-  portedComments?: PathToCommentsInfoMap;
-  portedDrafts?: PathToCommentsInfoMap;
-};
-
 /**
  * The CommentRange entity describes the range of an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 0726f67..8627591 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -158,6 +158,7 @@
   rootId?: UrlEncodedCommentId;
   diffSide?: Side;
   range?: CommentRange;
+  ported?: boolean; // is the comment ported over from a previous patchset
 }
 
 export function getLastComment(thread?: CommentThread): UIComment | undefined {