Merge "Revert "Use HTMLPatched for all html stanzas leading down to files.""
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 2f405e6..049f1d3 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -30,6 +30,7 @@
 
 nodejs_test(
     name = "web-test-runner",
+    size = "large",
     chdir = package_name(),
     data = [
         ":web-test-runner_config-sources",
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/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-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index f3c9d9c..b405e6b 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>
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index b58fc3f..1d3802a 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -259,11 +259,7 @@
     if (selectableChangeNums !== currentState.selectableChangeNums) return;
     const allDetailedChanges: Map<NumericChangeId, ChangeInfo> = new Map();
     for (const detailedChange of changeDetails ?? []) {
-      const basicChange = basicChanges.get(detailedChange._number)!;
-      allDetailedChanges.set(
-        detailedChange._number,
-        this.mergeOldAndDetailedChangeInfos(basicChange, detailedChange)
-      );
+      allDetailedChanges.set(detailedChange._number, detailedChange);
     }
     this.setState({
       ...currentState,
@@ -272,17 +268,6 @@
     });
   }
 
-  private mergeOldAndDetailedChangeInfos(
-    originalChange: ChangeInfo,
-    newData: ChangeInfo
-  ) {
-    return {
-      ...originalChange,
-      ...newData,
-      reviewers: originalChange.reviewers,
-    };
-  }
-
   private getNewReviewersToChange(
     change: ChangeInfo,
     state: ReviewerState,
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index 7bc37d6..2192246 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -14,10 +14,8 @@
   NumericChangeId,
   ChangeStatus,
   HttpMethod,
-  SubmitRequirementStatus,
   AccountInfo,
   ReviewerState,
-  AccountId,
   GroupInfo,
   Hashtag,
 } from '../../api/rest-api';
@@ -491,60 +489,6 @@
     );
   });
 
-  test('sync retains keys from original change including reviewers', async () => {
-    const c1: ChangeInfo = {
-      ...createChange(),
-      _number: 1 as NumericChangeId,
-      submit_requirements: [
-        {
-          name: 'a',
-          status: SubmitRequirementStatus.FORCED,
-          submittability_expression_result: {
-            expression: 'b',
-          },
-        },
-      ],
-      reviewers: {
-        REVIEWER: [{_account_id: 1 as AccountId, display_name: 'MyName'}],
-      },
-    };
-
-    stubRestApi('getDetailedChangesWithActions').callsFake(() => {
-      const change: ChangeInfo = {
-        ...createChange(),
-        _number: 1 as NumericChangeId,
-        actions: {abandon: {}},
-        // detailed data will be missing names
-        reviewers: {REVIEWER: [createAccountWithIdNameAndEmail()]},
-      };
-      assert.isNotOk(change.submit_requirements);
-      return Promise.resolve([change]);
-    });
-
-    bulkActionsModel.sync([c1]);
-
-    await waitUntilObserved(
-      bulkActionsModel.loadingState$,
-      s => s === LoadingState.LOADED
-    );
-
-    const changeAfterSync = bulkActionsModel
-      .getState()
-      .allChanges.get(1 as NumericChangeId);
-    assert.deepEqual(changeAfterSync!.submit_requirements, [
-      {
-        name: 'a',
-        status: SubmitRequirementStatus.FORCED,
-        submittability_expression_result: {
-          expression: 'b',
-        },
-      },
-    ]);
-    assert.deepEqual(changeAfterSync!.actions, {abandon: {}});
-    // original reviewers are kept, which includes more details than loaded ones
-    assert.deepEqual(changeAfterSync!.reviewers, c1.reviewers);
-  });
-
   test('sync ignores outdated fetch responses', async () => {
     const c1 = createChange();
     c1._number = 1 as NumericChangeId;
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}`
     );
   });