Merge "Prepare bulk actions model to take RelatedChangeAndCommitInfo"
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index a7af005..3c06eb0 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -453,6 +453,9 @@
 export declare interface CommentLinkInfo {
   match: string;
   link?: string;
+  prefix?: string;
+  suffix?: string;
+  text?: string;
   enabled?: boolean;
   html?: string;
 }
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 76ec316..f2977dc 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
@@ -15,7 +15,7 @@
 import '../../shared/gr-change-star/gr-change-star';
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-editable-content/gr-editable-content';
-import '../../shared/gr-formatted-text/gr-formatted-text';
+import '../../shared/gr-linked-text/gr-linked-text';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-change-actions/gr-change-actions';
@@ -191,6 +191,7 @@
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
+const REVIEWERS_REGEX = /^(R|CC)=/gm;
 const MIN_CHECK_INTERVAL_SECS = 0;
 
 const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
@@ -958,7 +959,7 @@
           /* Account for border and padding and rounding errors. */
           max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
         }
-        .commitMessage gr-formatted-text {
+        .commitMessage gr-linked-text {
           word-break: break-word;
         }
         #commitMessageEditor {
@@ -1459,10 +1460,12 @@
                 .commitCollapsible=${this.computeCommitCollapsible()}
                 remove-zero-width-space=""
               >
-                <gr-formatted-text
-                  .markdown=${false}
-                  .content=${this.latestCommitMessage ?? ''}
-                ></gr-formatted-text>
+                <gr-linked-text
+                  pre=""
+                  .content=${this.latestCommitMessage}
+                  .config=${this.projectConfig?.commentlinks}
+                  remove-zero-width-space=""
+                ></gr-linked-text>
               </gr-editable-content>
             </div>
             <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
@@ -1821,7 +1824,7 @@
           return;
         }
 
-        this.latestCommitMessage = message;
+        this.latestCommitMessage = this.prepareCommitMsgForLinkify(message);
         this.editingCommitMessage = false;
         this.reloadWindow();
       })
@@ -2671,6 +2674,14 @@
     this.changeViewAriaHidden = true;
   }
 
+  // Private but used in tests.
+  prepareCommitMsgForLinkify(msg: string) {
+    // TODO(wyatta) switch linkify sequence, see issue 5526.
+    // This is a zero-with space. It is added to prevent the linkify library
+    // from including R= or CC= as part of the email address.
+    return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
+  }
+
   /**
    * Utility function to make the necessary modifications to a change in the
    * case an edit exists.
@@ -2800,7 +2811,9 @@
       throw new Error('Could not find latest Revision Sha');
     const currentRevision = this.change.revisions[latestRevisionSha];
     if (currentRevision.commit && currentRevision.commit.message) {
-      this.latestCommitMessage = currentRevision.commit.message;
+      this.latestCommitMessage = this.prepareCommitMsgForLinkify(
+        currentRevision.commit.message
+      );
     } else {
       this.latestCommitMessage = null;
     }
@@ -2853,7 +2866,9 @@
       .getChangeCommitInfo(this.changeNum, lastpatchNum)
       .then(commitInfo => {
         if (!commitInfo) return;
-        this.latestCommitMessage = commitInfo.message;
+        this.latestCommitMessage = this.prepareCommitMsgForLinkify(
+          commitInfo.message
+        );
       });
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index e0c09e2..ad84fb0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -433,7 +433,9 @@
                         id="commitMessageEditor"
                         remove-zero-width-space=""
                       >
-                        <gr-formatted-text></gr-formatted-text>
+                        <gr-linked-text pre="" remove-zero-width-space="">
+                          <span id="output" slot="insert"></span>
+                        </gr-linked-text>
                       </gr-editable-content>
                     </div>
                     <h3 class="assistive-tech-only">
@@ -1407,6 +1409,20 @@
     assert.isTrue(overlayOpenStub.called);
   });
 
+  test('prepareCommitMsgForLinkify', () => {
+    let commitMessage = 'R=test@google.com';
+    let result = element.prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'R=\u200Btest@google.com');
+
+    commitMessage = 'R=test@google.com\nR=test@google.com';
+    result = element.prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
+
+    commitMessage = 'CC=test@google.com';
+    result = element.prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'CC=\u200Btest@google.com');
+  });
+
   test('_isSubmitEnabled', () => {
     assert.isFalse(element.isSubmitEnabled());
     element.currentRevisionActions = {submit: {}};
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 a0e4c89..731f227 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
@@ -882,7 +882,7 @@
   override render() {
     this.classList.toggle('editMode', this.editMode);
     const patchChange = this.calculatePatchChange();
-    return this.patched.html`
+    return html`
       <h3 class="assistive-tech-only">File list</h3>
       ${this.renderContainer()} ${this.renderChangeTotals(patchChange)}
       ${this.renderBinaryTotals(patchChange)} ${this.renderControlRow()}
@@ -895,7 +895,7 @@
   }
 
   private renderContainer() {
-    return this.patched.html`
+    return html`
       <div
         id="container"
         @click=${(e: MouseEvent) => this.handleFileListClick(e)}
@@ -1013,7 +1013,7 @@
     this.reportRenderedRow(index);
     const previousFileName = this.shownFiles[index - 1]?.__path;
     const patchSetFile = this.computePatchSetFile(file);
-    return this.patched.html` <div class="stickyArea">
+    return html` <div class="stickyArea">
       <div
         class=${`file-row row ${this.computePathClass(file.__path)}`}
         data-file=${JSON.stringify(patchSetFile)}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index b540c89..22f1325 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -257,9 +257,6 @@
   @state() serverConfig?: ServerInfo;
 
   @state()
-  patchsetLevelDraftMessage = '';
-
-  @state()
   filterReviewerSuggestion: (input: Suggestion) => boolean;
 
   @state()
@@ -382,7 +379,7 @@
   patchsetLevelDraftIsResolved = true;
 
   @state()
-  patchsetLevelComment?: UnsavedInfo | DraftInfo;
+  patchsetLevelComment: UnsavedInfo | DraftInfo = this.createDraft('');
 
   private readonly restApiService: RestApiService =
     getAppContext().restApiService;
@@ -673,7 +670,9 @@
     subscribe(
       this,
       () => this.getCommentsModel().patchsetLevelDrafts$,
-      x => (this.patchsetLevelComment = x[0])
+      x => {
+        if (x.length > 0) this.patchsetLevelComment = x[0];
+      }
     );
     subscribe(
       this,
@@ -764,7 +763,7 @@
       changedProperties.has('mentionedUsersInUnresolvedDrafts') ||
       changedProperties.has('includeComments') ||
       changedProperties.has('labelsChanged') ||
-      changedProperties.has('patchsetLevelDraftMessage') ||
+      changedProperties.has('patchsetLevelComment') ||
       changedProperties.has('mentionedCCs')
     ) {
       this.computeNewAttention();
@@ -916,10 +915,11 @@
   }
 
   // TODO: move to comment-util
-  private createDraft(): UnsavedInfo {
+  // Private but used in tests.
+  createDraft(message: string): UnsavedInfo {
     return {
       patch_set: this.latestPatchNum,
-      message: this.patchsetLevelDraftMessage,
+      message,
       unresolved: !this.patchsetLevelDraftIsResolved,
       path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
       __unsaved: true,
@@ -927,8 +927,6 @@
   }
 
   private renderPatchsetLevelComment() {
-    if (!this.patchsetLevelComment)
-      this.patchsetLevelComment = this.createDraft();
     return html`
       <gr-comment
         id="patchsetLevelComment"
@@ -938,7 +936,10 @@
           this.patchsetLevelDraftIsResolved = !e.detail.value;
         }}
         @comment-text-changed=${(e: ValueChangedEvent<string>) => {
-          this.patchsetLevelDraftMessage = e.detail.value;
+          const newMessage = e.detail.value;
+          if (this.patchsetLevelComment.message === newMessage) return;
+          this.patchsetLevelComment.message = newMessage;
+          this.requestUpdate('patchsetLevelComment');
         }}
         .messagePlaceholder=${this.messagePlaceholder}
         hide-header
@@ -1268,7 +1269,7 @@
     this.focusOn(focusTarget);
     if (quote?.length) {
       // If a reply quote has been provided, use it.
-      this.patchsetLevelDraftMessage = quote;
+      this.patchsetLevelComment = this.createDraft(quote);
     }
     if (this.restApiService.hasPendingDiffDrafts()) {
       this.savingComments = true;
@@ -1281,7 +1282,7 @@
 
   hasDrafts() {
     return (
-      this.patchsetLevelDraftMessage.length > 0 ||
+      !!this.patchsetLevelComment.message?.length ||
       this.draftCommentThreads.length > 0
     );
   }
@@ -1469,8 +1470,8 @@
           return;
         }
 
-        this.patchsetLevelDraftMessage = '';
         this.includeComments = true;
+        this.patchsetLevelComment = this.createDraft('');
         this.dispatchEvent(
           new CustomEvent('send', {
             composed: true,
@@ -2031,7 +2032,6 @@
   computeSendButtonDisabled() {
     if (
       this.canBeStarted === undefined ||
-      this.patchsetLevelDraftMessage === undefined ||
       this.reviewersMutated === undefined ||
       this.labelsChanged === undefined ||
       this.includeComments === undefined ||
@@ -2055,13 +2055,8 @@
     const revotingOrNewVote = this.labelsChanged || existingVote;
     const hasDrafts =
       (this.includeComments && this.draftCommentThreads.length > 0) ||
-      this.patchsetLevelDraftMessage.length > 0;
-    return (
-      !hasDrafts &&
-      !this.patchsetLevelDraftMessage.length &&
-      !this.reviewersMutated &&
-      !revotingOrNewVote
-    );
+      !!this.patchsetLevelComment?.message?.length;
+    return !hasDrafts && !this.reviewersMutated && !revotingOrNewVote;
   }
 
   computePatchSetWarning() {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index acd1755..cef787a 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -327,7 +327,9 @@
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
     await element.updateComplete;
-    element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
+    element.patchsetLevelComment = element.createDraft(
+      'I wholeheartedly disapprove'
+    );
     element.draftCommentThreads = [createCommentThread([createComment()])];
 
     element.includeComments = true;
@@ -1054,7 +1056,9 @@
   });
 
   test('label picker', async () => {
-    element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
+    element.patchsetLevelComment = element.createDraft(
+      'I wholeheartedly disapprove'
+    );
     element.draftCommentThreads = [createCommentThread([createComment()])];
 
     const saveReviewPromise = interceptSaveReview();
@@ -1075,7 +1079,7 @@
     const review = await saveReviewPromise;
     await element.updateComplete;
     await waitUntil(() => element.disabled === false);
-    assert.equal(element.patchsetLevelDraftMessage.length, 0);
+    assert.equal(element.patchsetLevelComment.message?.length, 0);
     assert.deepEqual(review, {
       drafts: 'PUBLISH_ALL_REVISIONS',
       labels: {
@@ -1101,7 +1105,9 @@
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
     await element.updateComplete;
-    element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
+    element.patchsetLevelComment = element.createDraft(
+      'I wholeheartedly disapprove'
+    );
 
     const saveReviewPromise = interceptSaveReview();
 
@@ -1616,7 +1622,7 @@
 
     assert.isFalse(element.attentionExpanded);
 
-    element.patchsetLevelDraftMessage = 'a test comment';
+    element.patchsetLevelComment = element.createDraft('a test comment');
     await element.updateComplete;
 
     modifyButton.click();
@@ -2066,11 +2072,11 @@
     const expectedError = new Error('test');
 
     setup(() => {
-      element.patchsetLevelDraftMessage = expectedDraft;
+      element.patchsetLevelComment = element.createDraft(expectedDraft);
     });
 
     function assertDialogOpenAndEnabled() {
-      assert.strictEqual(expectedDraft, element.patchsetLevelDraftMessage);
+      assert.strictEqual(expectedDraft, element.patchsetLevelComment.message);
       assert.isFalse(element.disabled);
     }
 
@@ -2116,7 +2122,7 @@
     // Mock canBeStarted
     element.canBeStarted = true;
     element.draftCommentThreads = [];
-    element.patchsetLevelDraftMessage = '';
+    element.patchsetLevelComment = element.createDraft('');
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2130,7 +2136,7 @@
     // Mock everything false
     element.canBeStarted = false;
     element.draftCommentThreads = [];
-    element.patchsetLevelDraftMessage = '';
+    element.patchsetLevelComment = element.createDraft('');
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2144,7 +2150,7 @@
     // Mock nonempty comment draft array; with sending comments.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.patchsetLevelDraftMessage = '';
+    element.patchsetLevelComment = element.createDraft('');
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = true;
@@ -2158,7 +2164,7 @@
     // Mock nonempty comment draft array; without sending comments.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.patchsetLevelDraftMessage = '';
+    element.patchsetLevelComment = element.createDraft('');
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2173,7 +2179,7 @@
     // Mock nonempty change message.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.patchsetLevelDraftMessage = 'test';
+    element.patchsetLevelComment = element.createDraft('test');
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2188,7 +2194,7 @@
     // Mock reviewers mutated.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.patchsetLevelDraftMessage = '';
+    element.patchsetLevelComment = element.createDraft('');
     element.reviewersMutated = true;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2203,7 +2209,7 @@
     // Mock labels changed.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.patchsetLevelDraftMessage = '';
+    element.patchsetLevelComment = element.createDraft('');
     element.reviewersMutated = false;
     element.labelsChanged = true;
     element.includeComments = false;
@@ -2218,7 +2224,7 @@
     // Whole dialog is disabled.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.patchsetLevelDraftMessage = '';
+    element.patchsetLevelComment = element.createDraft('');
     element.reviewersMutated = false;
     element.labelsChanged = true;
     element.includeComments = false;
@@ -2236,7 +2242,7 @@
     ).all = [account];
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
-    element.patchsetLevelDraftMessage = '';
+    element.patchsetLevelComment = element.createDraft('');
     element.reviewersMutated = false;
     element.labelsChanged = false;
     element.includeComments = false;
@@ -2305,13 +2311,13 @@
 
       queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
         'hello';
-      await waitUntil(() => element.patchsetLevelDraftMessage === 'hello');
+      await waitUntil(() => element.patchsetLevelComment.message === 'hello');
 
       assert.isFalse(element.computeSendButtonDisabled());
 
       queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
         '';
-      await waitUntil(() => element.patchsetLevelDraftMessage === '');
+      await waitUntil(() => element.patchsetLevelComment.message === '');
 
       assert.isTrue(element.computeSendButtonDisabled());
     });
@@ -2327,7 +2333,7 @@
 
       patchsetLevelComment.messageText = 'hello world';
       await waitUntil(
-        () => element.patchsetLevelDraftMessage === 'hello world'
+        () => element.patchsetLevelComment.message === 'hello world'
       );
 
       const saveReviewPromise = interceptSaveReview();
@@ -2367,7 +2373,7 @@
       patchsetLevelComment.messageText = 'hello world';
 
       await waitUntil(
-        () => element.patchsetLevelDraftMessage === 'hello world'
+        () => element.patchsetLevelComment.message === 'hello world'
       );
       assert.deepEqual(autoSaveStub.callCount, 0);
 
@@ -2390,7 +2396,7 @@
       await waitUntil(() => element.draftCommentThreads.length === 1);
 
       // patchset level draft as a reply is not loaded in patchsetLevel comment
-      assert.equal(element.patchsetLevelDraftMessage, '');
+      assert.equal(element.patchsetLevelComment.message, '');
 
       assert.deepEqual(element.draftCommentThreads[0].comments[0], draft);
     });
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 eb9dc41..78a1509 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
@@ -452,7 +452,7 @@
       unresolved: this.unresolved,
       saving: this.saving,
     };
-    return this.patched.html`
+    return html`
       ${this.renderFilePath()}
       <div id="container">
         <h3 class="assistive-tech-only">${this.computeAriaHeading()}</h3>
@@ -504,7 +504,7 @@
     // because we ran into spurious issues with <gr-comment> being destroyed
     // and re-created when an unsaved draft transitions to 'saved' state.
     const draftComment = this.renderComment(this.getDraftOrUnsaved());
-    return this.patched.html`${publishedComments}${draftComment}`;
+    return html`${publishedComments}${draftComment}`;
   }
 
   private renderComment(comment?: Comment) {
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 ad2daf5..387554a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -968,8 +968,16 @@
 
   override willUpdate(changed: PropertyValues) {
     this.firstWillUpdate();
+    if (changed.has('editing') || changed.has('comment')) {
+      this.reflectCommentToInternalFields();
+    }
     if (changed.has('editing')) {
-      this.onEditingChanged();
+      // Parent components such as the reply dialog might be interested in whether
+      // come of their child components are in editing mode.
+      fire(this, 'comment-editing-changed', {
+        editing: this.editing,
+        path: this.comment?.path ?? '',
+      });
     }
     if (changed.has('unresolved')) {
       // The <gr-comment-thread> component wants to change its color based on
@@ -1047,22 +1055,14 @@
     throw new Error('unable to create preview fix event');
   }
 
-  private onEditingChanged() {
-    if (this.editing) {
-      this.collapsed = false;
-      this.messageText = this.comment?.message ?? '';
-      this.unresolved = this.comment?.unresolved ?? true;
-      this.originalMessage = this.messageText;
-      this.originalUnresolved = this.unresolved;
-      setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
-    }
-
-    // Parent components such as the reply dialog might be interested in whether
-    // come of their child components are in editing mode.
-    fire(this, 'comment-editing-changed', {
-      editing: this.editing,
-      path: this.comment?.path ?? '',
-    });
+  private reflectCommentToInternalFields() {
+    if (!this.editing) return;
+    this.collapsed = false;
+    this.messageText = this.comment?.message ?? '';
+    this.unresolved = this.comment?.unresolved ?? true;
+    this.originalMessage = this.messageText;
+    this.originalUnresolved = this.unresolved;
+    setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
   }
 
   // private, but visible for testing
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 8b651f1..d6a4d94 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -370,7 +370,10 @@
       content = this.content || '';
     }
 
-    this.newContent = content;
+    // TODO(wyatta) switch linkify sequence, see issue 5526.
+    this.newContent = this.removeZeroWidthSpace
+      ? content.replace(/^R=\u200B/gm, 'R=')
+      : content;
   }
 
   computeSaveDisabled(): boolean {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index da90b17..6ab0ec4 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -154,7 +154,17 @@
     //    for this.
     // 4. Rewrite plain text ("text") to apply linking and other config-based
     //    rewrites. Text within code blocks is not passed here.
+    // 5. Open links in a new tab by rendering with target="_blank" attribute.
     function customRenderer(renderer: {[type: string]: Function}) {
+      renderer['link'] = (href: string, title: string, text: string) =>
+        /* HTML */
+        `<a
+          href="${href}"
+          target="_blank"
+          ${title ? `title="${title}"` : ''}
+          rel="noopener"
+          >${text}</a
+        >`;
       renderer['image'] = (href: string, _title: string, text: string) =>
         `![${text}](${href})`;
       renderer['codespan'] = (text: string) =>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index f3c9d9c..6391347 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -51,7 +51,15 @@
         match: 'HTMLRewriteMe',
         html: '<div>HTMLRewritten</div>',
       },
+      complexLinkRewrite: {
+        match: '(^|\\s)A Link (\\d+)($|\\s)',
+        link: '/page?id=$2',
+        text: 'Link $2',
+        prefix: '$1A ',
+        suffix: '$3',
+      },
     });
+    self.CANONICAL_PATH = 'http://localhost';
     element = (
       await fixture(
         wrapInProvider(
@@ -72,6 +80,7 @@
     test('renders text with links and rewrites', async () => {
       element.content = `text with plain link: google.com
         \ntext with config link: LinkRewriteMe
+        \ntext with complex link: A Link 12
         \ntext with config html: HTMLRewriteMe`;
       await element.updateComplete;
 
@@ -91,6 +100,14 @@
             >
               LinkRewriteMe
             </a>
+            text with complex link: A
+            <a
+              href="http://localhost/page?id=12"
+              rel="noopener"
+              target="_blank"
+            >
+              Link 12
+            </a>
             text with config html:
             <div>HTMLRewritten</div>
           </pre>
@@ -129,6 +146,8 @@
       element.content = `text
         \ntext with plain link: google.com
         \ntext with config link: LinkRewriteMe
+        \ntext without a link: NotA Link 15 cats
+        \ntext with complex link: A Link 12
         \ntext with config html: HTMLRewriteMe`;
       await element.updateComplete;
 
@@ -154,6 +173,17 @@
                   LinkRewriteMe
                 </a>
               </p>
+              <p>text without a link: NotA Link 15 cats</p>
+              <p>
+                text with complex link: A
+                <a
+                  href="http://localhost/page?id=12"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  Link 12
+                </a>
+              </p>
               <p>text with config html:</p>
               <div>HTMLRewritten</div>
               <p></p>
@@ -302,7 +332,13 @@
             <div slot="markdown-html">
               <p>
                 @
-                <a href="mailto:someone@google.com"> someone@google.com </a>
+                <a
+                  href="mailto:someone@google.com"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  someone@google.com
+                </a>
               </p>
             </div>
           </marked-element>
@@ -353,7 +389,13 @@
             <div slot="markdown-html">
               <p>
                 <code>@</code>
-                <a href="mailto:someone@google.com"> someone@google.com </a>
+                <a
+                  href="mailto:someone@google.com"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  someone@google.com
+                </a>
               </p>
             </div>
           </marked-element>
@@ -371,7 +413,9 @@
           <marked-element>
             <div slot="markdown-html">
               <p>
-                <a href="https://www.google.com">myLink</a>
+                <a href="https://www.google.com" rel="noopener" target="_blank"
+                  >myLink</a
+                >
               </p>
             </div>
           </marked-element>
@@ -452,7 +496,9 @@
                 <p>block quote ${escapedDiv}</p>
               </blockquote>
               <p>
-                <a href="http://google.com">inline link ${escapedDiv}</a>
+                <a href="http://google.com" rel="noopener" target="_blank"
+                  >inline link ${escapedDiv}</a
+                >
               </p>
             </div>
           </marked-element>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
new file mode 100644
index 0000000..16a60e7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
@@ -0,0 +1,178 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../styles/shared-styles';
+import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {assertIsDefined} from '../../../utils/common-util';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-linked-text': GrLinkedText;
+  }
+}
+
+@customElement('gr-linked-text')
+export class GrLinkedText extends LitElement {
+  private outputElement?: HTMLSpanElement;
+
+  @property({type: Boolean, attribute: 'remove-zero-width-space'})
+  removeZeroWidthSpace?: boolean;
+
+  @property({type: String})
+  content = '';
+
+  @property({type: Boolean, attribute: true})
+  pre = false;
+
+  @property({type: Boolean, attribute: true})
+  disabled = false;
+
+  @property({type: Boolean, attribute: true})
+  inline = false;
+
+  @property({type: Object})
+  config?: LinkTextParserConfig;
+
+  static override get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+      :host([inline]) {
+        display: inline;
+      }
+      :host([pre]) ::slotted(span) {
+        white-space: var(--linked-text-white-space, pre-wrap);
+        word-wrap: var(--linked-text-word-wrap, break-word);
+      }
+    `;
+  }
+
+  override render() {
+    return html`<slot name="insert"></slot>`;
+  }
+
+  // NOTE: LinkTextParser dynamically creates HTML fragments based on backend
+  // configuration commentLinks. These commentLinks can contain arbitrary HTML
+  // fragments. This means that arbitrary HTML needs to be injected into the
+  // DOM-tree, where this HTML is is controlled on the server-side in the
+  // server-configuration rather than by arbitrary users.
+  // To enable this injection of 'unsafe' HTML, LinkTextParser generates
+  // HTML fragments. Lit does not support inserting html fragments directly
+  // into its DOM-tree as it controls the DOM-tree that it generates.
+  // Therefore, to get around this we create a single element that we slot into
+  // the Lit-owned DOM.  This element will not be part of this LitElement as
+  // it's slotted in and thus can be modified on the fly by handleParseResult.
+  override firstUpdated(_changedProperties: PropertyValues): void {
+    this.outputElement = document.createElement('span');
+    this.outputElement.id = 'output';
+    this.outputElement.slot = 'insert';
+    this.append(this.outputElement);
+  }
+
+  override updated(changedProperties: PropertyValues): void {
+    if (changedProperties.has('content') || changedProperties.has('config')) {
+      this._contentOrConfigChanged();
+    } else if (changedProperties.has('disabled')) {
+      this.styleLinks();
+    }
+  }
+
+  /**
+   * Because either the source text or the linkification config has changed,
+   * the content should be re-parsed.
+   * Private but used in tests.
+   *
+   * @param content The raw, un-linkified source string to parse.
+   * @param config The server config specifying commentLink patterns
+   */
+  _contentOrConfigChanged() {
+    if (!this.config) {
+      assertIsDefined(this.outputElement);
+      this.outputElement.textContent = this.content;
+      return;
+    }
+
+    assertIsDefined(this.outputElement);
+    this.outputElement.textContent = '';
+    const parser = new GrLinkTextParser(
+      this.config,
+      (text: string | null, href: string | null, fragment?: DocumentFragment) =>
+        this.handleParseResult(text, href, fragment),
+      this.removeZeroWidthSpace
+    );
+    parser.parse(this.content);
+
+    // Ensure that external links originating from HTML commentlink configs
+    // open in a new tab. @see Issue 5567
+    // Ensure links to the same host originating from commentlink configs
+    // open in the same tab. When target is not set - default is _self
+    // @see Issue 4616
+    this.outputElement.querySelectorAll('a').forEach(anchor => {
+      if (anchor.hostname === window.location.hostname) {
+        anchor.removeAttribute('target');
+      } else {
+        anchor.setAttribute('target', '_blank');
+      }
+      anchor.setAttribute('rel', 'noopener');
+    });
+
+    this.styleLinks();
+  }
+
+  /**
+   * Styles the links based on whether gr-linked-text is disabled or not
+   */
+  private styleLinks() {
+    assertIsDefined(this.outputElement);
+    this.outputElement.querySelectorAll('a').forEach(anchor => {
+      anchor.setAttribute('style', this.computeLinkStyle());
+    });
+  }
+
+  private computeLinkStyle() {
+    if (this.disabled) {
+      return `
+        color: inherit;
+        text-decoration: none;
+        pointer-events: none;
+      `;
+    } else {
+      return 'color: var(--link-color)';
+    }
+  }
+
+  /**
+   * This method is called when the GrLikTextParser emits a partial result
+   * (used as the "callback" parameter). It will be called in either of two
+   * ways:
+   * - To create a link: when called with `text` and `href` arguments, a link
+   *   element should be created and attached to the resulting DOM.
+   * - To attach an arbitrary fragment: when called with only the `fragment`
+   *   argument, the fragment should be attached to the resulting DOM as is.
+   */
+  private handleParseResult(
+    text: string | null,
+    href: string | null,
+    fragment?: DocumentFragment
+  ) {
+    assertIsDefined(this.outputElement);
+    const output = this.outputElement;
+    if (href) {
+      const a = document.createElement('a');
+      a.setAttribute('href', href);
+      // GrLinkTextParser either pass text and href together or
+      // only DocumentFragment - see LinkTextParserCallback
+      a.textContent = text!;
+      a.target = '_blank';
+      a.setAttribute('rel', 'noopener');
+      output.appendChild(a);
+    } else if (fragment) {
+      output.appendChild(fragment);
+    }
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
new file mode 100644
index 0000000..00e0313
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
@@ -0,0 +1,471 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-linked-text';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrLinkedText} from './gr-linked-text';
+import {queryAndAssert} from '../../../test/test-utils';
+
+suite('gr-linked-text tests', () => {
+  let element: GrLinkedText;
+
+  let originalCanonicalPath: string | undefined;
+
+  setup(async () => {
+    originalCanonicalPath = window.CANONICAL_PATH;
+    element = await fixture<GrLinkedText>(html`
+      <gr-linked-text>
+        <div id="output"></div>
+      </gr-linked-text>
+    `);
+
+    element.config = {
+      ph: {
+        match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
+        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+      },
+      prefixsameinlinkandpattern: {
+        match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
+        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+      },
+      changeid: {
+        match: '(I[0-9a-f]{8,40})',
+        link: '#/q/$1',
+      },
+      changeid2: {
+        match: 'Change-Id: +(I[0-9a-f]{8,40})',
+        link: '#/q/$1',
+      },
+      googlesearch: {
+        match: 'google:(.+)',
+        link: 'https://bing.com/search?q=$1', // html should supersede link.
+        html: '<a href="https://google.com/search?q=$1">$1</a>',
+      },
+      hashedhtml: {
+        match: 'hash:(.+)',
+        html: '<a href="#/awesomesauce">$1</a>',
+      },
+      baseurl: {
+        match: 'test (.+)',
+        html: '<a href="/r/awesomesauce">$1</a>',
+      },
+      anotatstartwithbaseurl: {
+        match: 'a test (.+)',
+        html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
+      },
+      disabledconfig: {
+        match: 'foo:(.+)',
+        link: 'https://google.com/search?q=$1',
+        enabled: false,
+      },
+    };
+  });
+
+  teardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('render', async () => {
+    element.content =
+      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <div id="output"></div>
+        <span id="output" slot="insert">
+          <a
+            href="https://bugs.chromium.org/p/gerrit/issues/detail?id=3650"
+            rel="noopener"
+            style="color: var(--link-color)"
+            target="_blank"
+          >
+            https://bugs.chromium.org/p/gerrit/issues/detail?id=3650
+          </a>
+        </span>
+      `
+    );
+  });
+
+  test('URL pattern was parsed and linked.', async () => {
+    // Regular inline link.
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    element.content = url;
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
+      .childNodes[0] as HTMLAnchorElement;
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.rel, 'noopener');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, url);
+  });
+
+  test('Bug pattern was parsed and linked', async () => {
+    // "Issue/Bug" pattern.
+    element.content = 'Issue 3650';
+    await element.updateComplete;
+
+    let linkEl = queryAndAssert(element, 'span#output')
+      .childNodes[0] as HTMLAnchorElement;
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'Issue 3650');
+
+    element.content = 'Bug 3650';
+    await element.updateComplete;
+
+    linkEl = queryAndAssert(element, 'span#output')
+      .childNodes[0] as HTMLAnchorElement;
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.rel, 'noopener');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'Bug 3650');
+  });
+
+  test('Pattern with same prefix as link was correctly parsed', async () => {
+    // Pattern starts with the same prefix (`http`) as the url.
+    element.content = 'httpexample 3650';
+    await element.updateComplete;
+
+    assert.equal(queryAndAssert(element, 'span#output').childNodes.length, 1);
+    const linkEl = queryAndAssert(element, 'span#output')
+      .childNodes[0] as HTMLAnchorElement;
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'httpexample 3650');
+  });
+
+  test('Change-Id pattern was parsed and linked', async () => {
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+    element.content = prefix + changeID;
+    await element.updateComplete;
+
+    const textNode = queryAndAssert(element, 'span#output').childNodes[0];
+    const linkEl = queryAndAssert(element, 'span#output')
+      .childNodes[1] as HTMLAnchorElement;
+    assert.equal(textNode.textContent, prefix);
+    const url = '/q/' + changeID;
+    assert.isFalse(linkEl.hasAttribute('target'));
+    // Since url is a path, the host is added automatically.
+    assert.isTrue(linkEl.href.endsWith(url));
+    assert.equal(linkEl.textContent, changeID);
+  });
+
+  test('Change-Id pattern was parsed and linked with base url', async () => {
+    window.CANONICAL_PATH = '/r';
+
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+    element.content = prefix + changeID;
+    await element.updateComplete;
+
+    const textNode = queryAndAssert(element, 'span#output').childNodes[0];
+    const linkEl = queryAndAssert(element, 'span#output')
+      .childNodes[1] as HTMLAnchorElement;
+    assert.equal(textNode.textContent, prefix);
+    const url = '/r/q/' + changeID;
+    assert.isFalse(linkEl.hasAttribute('target'));
+    // Since url is a path, the host is added automatically.
+    assert.isTrue(linkEl.href.endsWith(url));
+    assert.equal(linkEl.textContent, changeID);
+  });
+
+  test('Multiple matches', async () => {
+    element.content = 'Issue 3650\nIssue 3450';
+    await element.updateComplete;
+
+    const linkEl1 = queryAndAssert(element, 'span#output')
+      .childNodes[0] as HTMLAnchorElement;
+    const linkEl2 = queryAndAssert(element, 'span#output')
+      .childNodes[2] as HTMLAnchorElement;
+
+    assert.equal(linkEl1.target, '_blank');
+    assert.equal(
+      linkEl1.href,
+      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650'
+    );
+    assert.equal(linkEl1.textContent, 'Issue 3650');
+
+    assert.equal(linkEl2.target, '_blank');
+    assert.equal(
+      linkEl2.href,
+      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450'
+    );
+    assert.equal(linkEl2.textContent, 'Issue 3450');
+  });
+
+  test('Change-Id pattern parsed before bug pattern', async () => {
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+
+    // "Issue/Bug" pattern.
+    const bug = 'Issue 3650';
+
+    const changeUrl = '/q/' + changeID;
+    const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+
+    element.content = prefix + changeID + bug;
+    await element.updateComplete;
+
+    const textNode = queryAndAssert(element, 'span#output').childNodes[0];
+    const changeLinkEl = queryAndAssert(element, 'span#output')
+      .childNodes[1] as HTMLAnchorElement;
+    const bugLinkEl = queryAndAssert(element, 'span#output')
+      .childNodes[2] as HTMLAnchorElement;
+
+    assert.equal(textNode.textContent, prefix);
+
+    assert.isFalse(changeLinkEl.hasAttribute('target'));
+    assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
+    assert.equal(changeLinkEl.textContent, changeID);
+
+    assert.equal(bugLinkEl.target, '_blank');
+    assert.equal(bugLinkEl.href, bugUrl);
+    assert.equal(bugLinkEl.textContent, 'Issue 3650');
+  });
+
+  test('html field in link config', async () => {
+    element.content = 'google:do a barrel roll';
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
+      .childNodes[0] as HTMLAnchorElement;
+    assert.equal(
+      linkEl.getAttribute('href'),
+      'https://google.com/search?q=do a barrel roll'
+    );
+    assert.equal(linkEl.textContent, 'do a barrel roll');
+  });
+
+  test('removing hash from links', async () => {
+    element.content = 'hash:foo';
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
+      .childNodes[0] as HTMLAnchorElement;
+    assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('html with base url', async () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'test foo';
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
+      .childNodes[0] as HTMLAnchorElement;
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('a is not at start', async () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'a test foo';
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
+      .childNodes[1] as HTMLAnchorElement;
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('hash html with base url', async () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'hash:foo';
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
+      .childNodes[0] as HTMLAnchorElement;
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('disabled config', async () => {
+    element.content = 'foo:baz';
+    await element.updateComplete;
+
+    assert.equal(queryAndAssert(element, 'span#output').innerHTML, 'foo:baz');
+  });
+
+  test('R=email labels link correctly', async () => {
+    element.removeZeroWidthSpace = true;
+    element.content = 'R=\u200Btest@google.com';
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert(element, 'span#output').textContent,
+      'R=test@google.com'
+    );
+    assert.equal(
+      queryAndAssert(element, 'span#output').innerHTML.match(/(R=<a)/g)!.length,
+      1
+    );
+  });
+
+  test('CC=email labels link correctly', async () => {
+    element.removeZeroWidthSpace = true;
+    element.content = 'CC=\u200Btest@google.com';
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert(element, 'span#output').textContent,
+      'CC=test@google.com'
+    );
+    assert.equal(
+      queryAndAssert(element, 'span#output').innerHTML.match(/(CC=<a)/g)!
+        .length,
+      1
+    );
+  });
+
+  test('only {http,https,mailto} protocols are linkified', async () => {
+    element.content = 'xx mailto:test@google.com yy';
+    await element.updateComplete;
+
+    let links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+    element.content = 'xx http://google.com yy';
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'http://google.com');
+    assert.equal(links[0].innerHTML, 'http://google.com');
+
+    element.content = 'xx https://google.com yy';
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    element.content = 'xx ssh://google.com yy';
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+    assert.equal(links.length, 0);
+
+    element.content = 'xx ftp://google.com yy';
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+    assert.equal(links.length, 0);
+  });
+
+  test('links without leading whitespace are linkified', async () => {
+    element.content = 'xx abcmailto:test@google.com yy';
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
+      'xx abc'
+    );
+    let links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+    element.content = 'xx defhttp://google.com yy';
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
+      'xx def'
+    );
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'http://google.com');
+    assert.equal(links[0].innerHTML, 'http://google.com');
+
+    element.content = 'xx qwehttps://google.com yy';
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
+      'xx qwe'
+    );
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    // Non-latin character
+    element.content = 'xx абвhttps://google.com yy';
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
+      'xx абв'
+    );
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    element.content = 'xx ssh://google.com yy';
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+    assert.equal(links.length, 0);
+
+    element.content = 'xx ftp://google.com yy';
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+    assert.equal(links.length, 0);
+  });
+
+  test('overlapping links', async () => {
+    element.config = {
+      b1: {
+        match: '(B:\\s*)(\\d+)',
+        html: '$1<a href="ftp://foo/$2">$2</a>',
+      },
+      b2: {
+        match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
+        html: '$1<a href="ftp://foo/$2">$2</a>',
+      },
+    };
+    element.content = '- B: 123, 45';
+    await element.updateComplete;
+
+    const links = element.querySelectorAll('a');
+
+    assert.equal(links.length, 2);
+    assert.equal(
+      queryAndAssert<HTMLSpanElement>(element, 'span').textContent,
+      '- B: 123, 45'
+    );
+
+    assert.equal(links[0].href, 'ftp://foo/123');
+    assert.equal(links[0].textContent, '123');
+
+    assert.equal(links[1].href, 'ftp://foo/45');
+    assert.equal(links[1].textContent, '45');
+  });
+
+  test('_contentOrConfigChanged called with config', async () => {
+    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
+    element.content = 'some text';
+    await element.updateComplete;
+
+    assert.isTrue(contentConfigStub.called);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
new file mode 100644
index 0000000..73cf58b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
@@ -0,0 +1,415 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import 'ba-linkify/ba-linkify';
+import {getBaseUrl} from '../../../utils/url-util';
+import {CommentLinkInfo} from '../../../types/common';
+
+/**
+ * Pattern describing URLs with supported protocols.
+ */
+const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
+
+export type LinkTextParserCallback = ((text: string, href: string) => void) &
+  ((text: null, href: null, fragment: DocumentFragment) => void);
+
+export interface CommentLinkItem {
+  position: number;
+  length: number;
+  html: HTMLAnchorElement | DocumentFragment;
+}
+
+export type LinkTextParserConfig = {[name: string]: CommentLinkInfo};
+
+export class GrLinkTextParser {
+  private readonly baseUrl = getBaseUrl();
+
+  /**
+   * Construct a parser for linkifying text. Will linkify plain URLs that appear
+   * in the text as well as custom links if any are specified in the linkConfig
+   * parameter.
+   *
+   * @param linkConfig Comment links as specified by the commentlinks field on a
+   *     project config.
+   * @param callback The callback to be fired when an intermediate parse result
+   *     is emitted. The callback is passed text and href strings if a link is to
+   *     be created, or a document fragment otherwise.
+   * @param removeZeroWidthSpace If true, zero-width spaces will be removed from
+   *     R=<email> and CC=<email> expressions.
+   */
+  constructor(
+    private readonly linkConfig: LinkTextParserConfig,
+    private readonly callback: LinkTextParserCallback,
+    private readonly removeZeroWidthSpace?: boolean
+  ) {
+    Object.preventExtensions(this);
+  }
+
+  /**
+   * Emit a callback to create a link element.
+   *
+   * @param text The text of the link.
+   * @param href The URL to use as the href of the link.
+   */
+  addText(text: string, href: string) {
+    if (!text) {
+      return;
+    }
+    this.callback(text, href);
+  }
+
+  /**
+   * Given the source text and a list of CommentLinkItem objects that were
+   * generated by the commentlinks config, emit parsing callbacks.
+   *
+   * @param text The chuml of source text over which the outputArray items range.
+   * @param outputArray The list of items to add resulting from commentlink
+   *     matches.
+   */
+  processLinks(text: string, outputArray: CommentLinkItem[]) {
+    this.sortArrayReverse(outputArray);
+    const fragment = document.createDocumentFragment();
+    let cursor = text.length;
+
+    // Start inserting linkified URLs from the end of the String. That way, the
+    // string positions of the items don't change as we iterate through.
+    outputArray.forEach(item => {
+      // Add any text between the current linkified item and the item added
+      // before if it exists.
+      if (item.position + item.length !== cursor) {
+        fragment.insertBefore(
+          document.createTextNode(
+            text.slice(item.position + item.length, cursor)
+          ),
+          fragment.firstChild
+        );
+      }
+      fragment.insertBefore(item.html, fragment.firstChild);
+      cursor = item.position;
+    });
+
+    // Add the beginning portion at the end.
+    if (cursor !== 0) {
+      fragment.insertBefore(
+        document.createTextNode(text.slice(0, cursor)),
+        fragment.firstChild
+      );
+    }
+
+    this.callback(null, null, fragment);
+  }
+
+  /**
+   * Sort the given array of CommentLinkItems such that the positions are in
+   * reverse order.
+   */
+  sortArrayReverse(outputArray: CommentLinkItem[]) {
+    outputArray.sort((a, b) => b.position - a.position);
+  }
+
+  addItem(
+    text: string,
+    href: string,
+    html: null,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ): void;
+
+  addItem(
+    text: null,
+    href: null,
+    html: string,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ): void;
+
+  /**
+   * Create a CommentLinkItem and append it to the given output array. This
+   * method can be called in either of two ways:
+   * - With `text` and `href` parameters provided, and the `html` parameter
+   *   passed as `null`. In this case, the new CommentLinkItem will be a link
+   *   element with the given text and href value.
+   * - With the `html` paremeter provided, and the `text` and `href` parameters
+   *   passed as `null`. In this case, the string of HTML will be parsed and the
+   *   first resulting node will be used as the resulting content.
+   *
+   * @param text The text to use if creating a link.
+   * @param href The href to use as the URL if creating a link.
+   * @param html The html to parse and use as the result.
+   * @param  position The position inside the source text where the item
+   *     starts.
+   * @param length The number of characters in the source text
+   *     represented by the item.
+   * @param outputArray The array to which the
+   *     new item is to be appended.
+   */
+  addItem(
+    text: string | null,
+    href: string | null,
+    html: string | null,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ): void {
+    if (href) {
+      const a = document.createElement('a');
+      a.setAttribute('href', href);
+      a.textContent = text;
+      a.target = '_blank';
+      a.rel = 'noopener';
+      outputArray.push({
+        html: a,
+        position,
+        length,
+      });
+    } else if (html) {
+      // addItem has 2 overloads. If href is null, then html
+      // can't be null.
+      // TODO(TS): remove if(html) and keep else block without condition
+      const fragment = document.createDocumentFragment();
+      // Create temporary div to hold the nodes in.
+      const div = document.createElement('div');
+      div.innerHTML = html;
+      while (div.firstChild) {
+        fragment.appendChild(div.firstChild);
+      }
+      outputArray.push({
+        html: fragment,
+        position,
+        length,
+      });
+    }
+  }
+
+  /**
+   * Create a CommentLinkItem for a link and append it to the given output
+   * array.
+   *
+   * @param text The text for the link.
+   * @param href The href to use as the URL of the link.
+   * @param position The position inside the source text where the link
+   *     starts.
+   * @param length The number of characters in the source text
+   *     represented by the link.
+   * @param outputArray The array to which the
+   *     new item is to be appended.
+   */
+  addLink(
+    text: string,
+    href: string,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ) {
+    // TODO(TS): remove !test condition
+    if (!text || this.hasOverlap(position, length, outputArray)) {
+      return;
+    }
+    if (
+      !!this.baseUrl &&
+      href.startsWith('/') &&
+      !href.startsWith(this.baseUrl)
+    ) {
+      href = this.baseUrl + href;
+    }
+    this.addItem(text, href, null, position, length, outputArray);
+  }
+
+  /**
+   * Create a CommentLinkItem specified by an HTMl string and append it to the
+   * given output array.
+   *
+   * @param html The html to parse and use as the result.
+   * @param position The position inside the source text where the item
+   *     starts.
+   * @param length The number of characters in the source text
+   *     represented by the item.
+   * @param outputArray The array to which the
+   *     new item is to be appended.
+   */
+  addHTML(
+    html: string,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ) {
+    if (this.hasOverlap(position, length, outputArray)) {
+      return;
+    }
+    if (
+      !!this.baseUrl &&
+      html.match(/<a href="\//g) &&
+      !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)
+    ) {
+      html = html.replace(/<a href="\//g, `<a href="${this.baseUrl}/`);
+    }
+    this.addItem(null, null, html, position, length, outputArray);
+  }
+
+  /**
+   * Does the given range overlap with anything already in the item list.
+   */
+  hasOverlap(position: number, length: number, outputArray: CommentLinkItem[]) {
+    const endPosition = position + length;
+    for (let i = 0; i < outputArray.length; i++) {
+      const arrayItemStart = outputArray[i].position;
+      const arrayItemEnd = outputArray[i].position + outputArray[i].length;
+      if (
+        (position >= arrayItemStart && position < arrayItemEnd) ||
+        (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
+        (position === arrayItemStart && position === arrayItemEnd)
+      ) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Parse the given source text and emit callbacks for the items that are
+   * parsed.
+   */
+  parse(text?: string | null) {
+    if (text) {
+      window.linkify(text, {
+        callback: (text: string, href?: string) => this.parseChunk(text, href),
+      });
+    }
+  }
+
+  /**
+   * Callback that is pased into the linkify function. ba-linkify will call this
+   * method in either of two ways:
+   * - With both a `text` and `href` parameter provided: this indicates that
+   *   ba-linkify has found a plain URL and wants it linkified.
+   * - With only a `text` parameter provided: this represents the non-link
+   *   content that lies between the links the library has found.
+   *
+   */
+  parseChunk(text: string, href?: string) {
+    // TODO(wyatta) switch linkify sequence, see issue 5526.
+    if (this.removeZeroWidthSpace) {
+      // Remove the zero-width space added in gr-change-view.
+      text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
+    }
+
+    // If the href is provided then ba-linkify has recognized it as a URL. If
+    // the source text does not include a protocol, the protocol will be added
+    // by ba-linkify. Create the link if the href is provided and its protocol
+    // matches the expected pattern.
+    if (href) {
+      const result = URL_PROTOCOL_PATTERN.exec(href);
+      if (result) {
+        const prefixText = result[1];
+        if (prefixText.length > 0) {
+          // Fix for simple cases from
+          // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
+          // When leading whitespace is missed before link,
+          // linkify add this text before link as a schema name to href.
+          // We suppose, that prefixText just a single word
+          // before link and add this word as is, without processing
+          // any patterns in it.
+          this.parseLinks(prefixText, {});
+          text = text.substring(prefixText.length);
+          href = href.substring(prefixText.length);
+        }
+        this.addText(text, href);
+        return;
+      }
+    }
+    // For the sections of text that lie between the links found by
+    // ba-linkify, we search for the project-config-specified link patterns.
+    this.parseLinks(text, this.linkConfig);
+  }
+
+  /**
+   * Walk over the given source text to find matches for comemntlink patterns
+   * and emit parse result callbacks.
+   *
+   * @param text The raw source text.
+   * @param config A comment links specification object.
+   */
+  parseLinks(text: string, config: LinkTextParserConfig) {
+    // The outputArray is used to store all of the matches found for all
+    // patterns.
+    const outputArray: CommentLinkItem[] = [];
+    for (const [configName, linkInfo] of Object.entries(config)) {
+      // TODO(TS): it seems, the following line can be rewritten as:
+      // if(enabled === false || enabled === 0 || enabled === '')
+      // Should be double-checked before update
+      // eslint-disable-next-line eqeqeq
+      if (linkInfo.enabled != null && linkInfo.enabled == false) {
+        continue;
+      }
+      // PolyGerrit doesn't use hash-based navigation like the GWT UI.
+      // Account for this.
+      const html = linkInfo.html;
+      const link = linkInfo.link;
+      if (html) {
+        linkInfo.html = html.replace(/<a href="#\//g, '<a href="/');
+      } else if (link) {
+        if (link[0] === '#') {
+          linkInfo.link = link.substr(1);
+        }
+      }
+
+      const pattern = new RegExp(linkInfo.match, 'g');
+
+      let match;
+      let textToCheck = text;
+      let susbtrIndex = 0;
+
+      while ((match = pattern.exec(textToCheck))) {
+        textToCheck = textToCheck.substr(match.index + match[0].length);
+        let result = match[0].replace(
+          pattern,
+          // Either html or link has a value. Otherwise an exception is thrown
+          // in the code below.
+          (linkInfo.html || linkInfo.link)!
+        );
+
+        if (linkInfo.html) {
+          let i;
+          // Skip portion of replacement string that is equal to original to
+          // allow overlapping patterns.
+          for (i = 0; i < result.length; i++) {
+            if (result[i] !== match[0][i]) {
+              break;
+            }
+          }
+          result = result.slice(i);
+
+          this.addHTML(
+            result,
+            susbtrIndex + match.index + i,
+            match[0].length - i,
+            outputArray
+          );
+        } else if (linkInfo.link) {
+          this.addLink(
+            match[0],
+            result,
+            susbtrIndex + match.index,
+            match[0].length,
+            outputArray
+          );
+        } else {
+          throw Error(
+            'linkconfig entry ' +
+              configName +
+              ' doesn’t contain a link or html attribute.'
+          );
+        }
+
+        // Update the substring location so we know where we are in relation to
+        // the initial full text string.
+        susbtrIndex = susbtrIndex + match.index + match[0].length;
+      }
+    }
+    this.processLinks(text, outputArray);
+  }
+}
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index bb46f27..8e058aa 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -574,6 +574,8 @@
     summaryMessage: string | undefined,
     patchset: ChecksPatchset
   ) {
+    // Protect against plugins not respecting required fields.
+    runs = runs.filter(run => !!run.checkName && !!run.status);
     const attemptMap = createAttemptMap(runs);
     for (const attemptInfo of attemptMap.values()) {
       attemptInfo.attempts.sort(sortAttemptDetails);
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index f3fc665..88fbebc 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -147,6 +147,35 @@
     assert.lengthOf(current.runs[0].results!, 1);
   });
 
+  test('model.updateStateSetResults ignore empty name or status', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      [
+        {
+          checkName: 'test-check-name',
+          status: RunStatus.COMPLETED,
+        },
+        // Will be ignored, because the checkName is empty.
+        {
+          checkName: undefined as unknown as string,
+          status: RunStatus.COMPLETED,
+        },
+        // Will be ignored, because the status is empty.
+        {
+          checkName: 'test-check-name',
+          status: undefined as unknown as RunStatus,
+        },
+      ],
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    // 2 out of 3 runs are ignored.
+    assert.lengthOf(current.runs, 1);
+  });
+
   test('model.updateStateUpdateResult', () => {
     model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
     model.updateStateSetResults(
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index a7d0587..207152c 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -29,7 +29,7 @@
 const SUGGESTIONS_LIMIT = 15;
 // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
 export const MENTIONS_REGEX =
-  /(?<=^|\s)@([a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?=\s+|$)/g;
+  /(?:^|\s)@([a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?=\s+|$)/g;
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
   if (account._account_id !== undefined) return account._account_id;
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
index fd5965b..9079f4c 100644
--- a/polygerrit-ui/app/utils/link-util.ts
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -18,7 +18,7 @@
   const parts: string[] = [];
   window.linkify(baseWithZeroWidthSpace, {
     callback: (text, href) => {
-      const result = href ? createLinkTemplate(text, href) : text;
+      const result = href ? createLinkTemplate(href, text) : text;
       const resultWithoutZeroWidthSpace = result.replace(/\u200B/g, '');
       parts.push(resultWithoutZeroWidthSpace);
     },
@@ -39,7 +39,12 @@
       : rewrite.link!;
     return {
       match: new RegExp(rewrite.match, 'g'),
-      replace: createLinkTemplate('$&', replacementHref),
+      replace: createLinkTemplate(
+        replacementHref,
+        rewrite.text ?? '$&',
+        rewrite.prefix,
+        rewrite.suffix
+      ),
     };
   });
   return applyRewrites(base, rewrites);
@@ -71,6 +76,15 @@
   );
 }
 
-function createLinkTemplate(displayText: string, href: string) {
-  return `<a href="${href}" rel="noopener" target="_blank">${displayText}</a>`;
+function createLinkTemplate(
+  href: string,
+  displayText: string,
+  prefix?: string,
+  suffix?: string
+) {
+  return `${
+    prefix ?? ''
+  }<a href="${href}" rel="noopener" target="_blank">${displayText}</a>${
+    suffix ?? ''
+  }`;
 }
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
index c491e35..a1ec2fa 100644
--- a/polygerrit-ui/app/utils/link-util_test.ts
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -30,11 +30,13 @@
       '<h1>Change 12345 is the best change</h1> <div>FOO</div>'
     );
   });
+
   test('applyLinkRewritesFromConfig', () => {
     const linkedNumber = link('#12345', 'google.com/12345');
     const linkedFoo = link('foo', 'foo.gov');
+    const linkedBar = link('Bar Page: 300', 'bar.com/page?id=300');
     assert.equal(
-      applyLinkRewritesFromConfig('#12345 foo', {
+      applyLinkRewritesFromConfig('#12345 foo crowbar:12 bar:300', {
         'number-linker': {
           match: '#(\\d+)',
           link: 'google.com/$1',
@@ -43,8 +45,15 @@
           match: 'foo',
           link: 'foo.gov',
         },
+        'advanced-link': {
+          match: '(^|\\s)bar:(\\d+)($|\\s)',
+          link: 'bar.com/page?id=$2',
+          text: 'Bar Page: $2',
+          prefix: '$1',
+          suffix: '$3',
+        },
       }),
-      `${linkedNumber} ${linkedFoo}`
+      `${linkedNumber} ${linkedFoo} crowbar:12 ${linkedBar}`
     );
   });