Merge "Add aria-label for text line"
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 8f36cfb..e84a698 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -89,6 +89,7 @@
 
 * `change/submit_rule_evaluation`: Latency for evaluating submit rules on a change.
 * `change/submit_type_evaluation`: Latency for evaluating the submit type on a change.
+* `change/post_review/draft_handling`: Total number of draft handling option (KEEP, PUBLISH, PUBLISH_ALL_REVISIONS) selected by users while posting a review.
 
 === Comments
 
diff --git a/java/com/google/gerrit/server/DeadlineChecker.java b/java/com/google/gerrit/server/DeadlineChecker.java
index 5662e50..f41b1e3 100644
--- a/java/com/google/gerrit/server/DeadlineChecker.java
+++ b/java/com/google/gerrit/server/DeadlineChecker.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.cancellation.RequestStateProvider;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.HashSet;
@@ -134,7 +135,7 @@
     this(
         serverConfig,
         cancellationsMetrics,
-        System.nanoTime(),
+        TimeUtil.nowNanos(),
         requestInfo,
         clientProvidedTimeoutValue);
   }
@@ -236,7 +237,7 @@
 
   @Override
   public void checkIfCancelled(OnCancelled onCancelled) {
-    long now = System.nanoTime();
+    long now = TimeUtil.nowNanos();
 
     Set<String> exceededAdvisoryDeadlines = new HashSet<>();
     advisoryDeadlines
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 42fc916..f7f58fc 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -627,7 +627,7 @@
   ReceiveCommitsResult processCommands(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) throws StorageException {
     checkState(!used, "Tried to re-use a ReceiveCommits objects that is single-use only");
-    long start = System.nanoTime();
+    long start = TimeUtil.nowNanos();
     parsePushOptions();
     String clientProvidedDeadlineValue =
         Iterables.getLast(pushOptions.get("deadline"), /* defaultValue= */ null);
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 4dbb6ee..94dc21f 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -80,6 +80,10 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -151,6 +155,25 @@
 public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @Singleton
+  private static class Metrics {
+    final Counter1<String> draftHandling;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      draftHandling =
+          metricMaker.newCounter(
+              "change/post_review/draft_handling",
+              new Description(
+                      "Total number of draft handling option "
+                          + "(KEEP, PUBLISH, PUBLISH_ALL_REVISIONS) "
+                          + "selected by users while posting a review.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("type", Metadata.Builder::eventType).build());
+    }
+  }
+
   private static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
@@ -170,6 +193,7 @@
   private final EmailReviewComments.Factory email;
   private final CommentAdded commentAdded;
   private final ReviewerModifier reviewerModifier;
+  private final Metrics metrics;
   private final ModifyReviewersEmail modifyReviewersEmail;
   private final NotifyResolver notifyResolver;
   private final WorkInProgressOp.Factory workInProgressOpFactory;
@@ -196,6 +220,7 @@
       EmailReviewComments.Factory email,
       CommentAdded commentAdded,
       ReviewerModifier reviewerModifier,
+      Metrics metrics,
       ModifyReviewersEmail modifyReviewersEmail,
       NotifyResolver notifyResolver,
       @GerritServerConfig Config gerritConfig,
@@ -218,6 +243,7 @@
     this.email = email;
     this.commentAdded = commentAdded;
     this.reviewerModifier = reviewerModifier;
+    this.metrics = metrics;
     this.modifyReviewersEmail = modifyReviewersEmail;
     this.notifyResolver = notifyResolver;
     this.workInProgressOpFactory = workInProgressOpFactory;
@@ -252,6 +278,7 @@
 
     logger.atFine().log("strict label checking is %s", (strictLabels ? "enabled" : "disabled"));
 
+    metrics.draftHandling.increment(input.drafts == null ? "N/A" : input.drafts.name());
     input.drafts = firstNonNull(input.drafts, DraftHandling.KEEP);
     logger.atFine().log("draft handling = %s", input.drafts);
 
diff --git a/java/com/google/gerrit/server/util/time/TimeUtil.java b/java/com/google/gerrit/server/util/time/TimeUtil.java
index 639d0a6..54ef305 100644
--- a/java/com/google/gerrit/server/util/time/TimeUtil.java
+++ b/java/com/google/gerrit/server/util/time/TimeUtil.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.util.git.DelegateSystemReader;
 import java.sql.Timestamp;
 import java.time.Instant;
+import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
 import org.eclipse.jgit.util.SystemReader;
 
@@ -35,6 +36,10 @@
     return currentMillisSupplier.getAsLong();
   }
 
+  public static long nowNanos() {
+    return TimeUnit.NANOSECONDS.convert(TimeUtil.nowMs(), TimeUnit.MILLISECONDS);
+  }
+
   public static Instant now() {
     return Instant.ofEpochMilli(nowMs());
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 85a7b29..875ce97 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -156,15 +156,15 @@
   @UseClockStep
   @Test
   public void addedRobotCommentsAreLinkedToChangeMessages() throws Exception {
-    TestTimeUtil.resetWithClockStep(0, TimeUnit.SECONDS);
-    createChange();
-    /* Advancing the time after creating the change so that the first robot comment is not in the same timestamp as with the change creation */
+    // Advancing the time after creating the change so that the first robot comment is not in the
+    // same timestamp as with the change creation.
     TestTimeUtil.incrementClock(10, TimeUnit.SECONDS);
 
     RobotCommentInput c1 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
     RobotCommentInput c2 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
     RobotCommentInput c3 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
-    /* Give the robot comments identifiable names for testing */
+
+    // Give the robot comments identifiable names for testing
     c1.message = "robot comment 1";
     c2.message = "robot comment 2";
     c3.message = "robot comment 3";
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
index 88d0937..943d990 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
@@ -21,6 +21,7 @@
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -116,6 +117,28 @@
     assertDeleteRef(OK);
   }
 
+  @Test
+  public void directPushSendsEmail() throws Exception {
+    // create a change
+    PushOneCommit push1 =
+        pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+    PushOneCommit.Result r = push1.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Add reviewer to receive notifications
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    sender.clear();
+
+    // direct submit the change
+    PushOneCommit.Result r1 = push1.to("refs/heads/master");
+    r1.assertOkStatus();
+
+    // email received
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains("has submitted this change");
+  }
+
   private void assertDeleteRef(RemoteRefUpdate.Status expectedStatus) throws Exception {
     BranchInput in = new BranchInput();
     in.ref = "refs/heads/test";
diff --git a/package.json b/package.json
index 6ad3ab4..c3dfad0 100644
--- a/package.json
+++ b/package.json
@@ -33,8 +33,8 @@
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
     "polylint": "npm run safe_bazelisk test polygerrit-ui/app:polylint_test",
-    "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --browsers ChromeDev --no-single-run --testFiles",
-    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles",
+    "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --browsers ChromeDev --no-single-run --test-files",
+    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --test-files",
     "postinstall": "(git apply --reverse --ignore-whitespace twinkie.patch || true) && git apply --ignore-whitespace twinkie.patch",
     "polytest": "npm run safe_bazelisk test //polygerrit-ui/app:validate_polymer_templates",
     "polytest:dev": "rm -rf ./polygerrit-ui/app/tmpl_out && npm run safe_bazelisk build //polygerrit-ui/app:template_test_tar && mkdir ./polygerrit-ui/app/tmpl_out && tar -xf bazel-bin/polygerrit-ui/app/template_test_tar.tar -C ./polygerrit-ui/app/tmpl_out"
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 7bca96d..62d1d92 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -1,5 +1,6 @@
 load("@io_bazel_rules_go//go:def.bzl", "go_binary")
 load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:js.bzl", "karma_test")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -33,8 +34,6 @@
     ],
 )
 
-# Define a karma+plugins binary to run karma-mocha tests.
-# Can be reused multiple time, if there are multiple karma test rules
 sh_binary(
     name = "karma_bin",
     srcs = ["@ui_dev_npm//:node_modules/karma/bin/karma"],
@@ -49,26 +48,8 @@
     ],
 )
 
-# Run all tests in one.
-# TODO(dmfilippov): allow parallel tests for karma - either on the bazel level
-# or on the karma level. For now single sh_test is enough.
-sh_test(
+karma_test(
     name = "karma_test",
-    size = "enormous",
     srcs = ["karma_test.sh"],
-    args = [
-        "$(location :karma_bin)",
-        "$(location karma.conf.js)",
-    ],
-    data = [
-        "karma.conf.js",
-        ":karma_bin",
-        "//polygerrit-ui/app:test-srcs-fg",
-    ],
-    # Should not run sandboxed.
-    tags = [
-        "karma",
-        "local",
-        "manual",
-    ],
+    data = ["//polygerrit-ui/app:test-srcs-fg"],
 )
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 1908df0..6dd67e4 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -41,6 +41,7 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 export interface GrApplyFixDialog {
   $: {
@@ -98,7 +99,11 @@
   })
   _disableApplyFixButton = false;
 
-  layers = [new TokenHighlightLayer(this)];
+  layers = appContext.flagsService.isEnabled(
+    KnownExperimentId.TOKEN_HIGHLIGHTING
+  )
+    ? [new TokenHighlightLayer(this)]
+    : [];
 
   private refitOverlay?: () => void;
 
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 fa3ddf4..c4fed53 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
@@ -412,7 +412,11 @@
 
   private _getLayers(path: string): DiffLayer[] {
     const layers = [];
-    layers.push(new TokenHighlightLayer(this));
+    if (
+      appContext.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)
+    ) {
+      layers.push(new TokenHighlightLayer(this));
+    }
     layers.push(this.syntaxLayer);
     // Get layers from plugins (if any).
     layers.push(...this.jsAPI.getDiffLayers(path));
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 b24b3ba..344f9d8 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
@@ -1270,7 +1270,7 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
+      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
     });
 
     test('rendering normal-sized diff does not disable syntax', () => {
@@ -1324,7 +1324,7 @@
     test('gr-diff-host provides syntax highlighting layer', async () => {
       stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
+      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
     });
 
     test('syntax layer should be disabled', () => {
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 39a87a2..d6aae5c 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
@@ -54,6 +54,7 @@
 import {CustomKeyboardEvent} from '../../../types/events';
 import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
+import {KnownExperimentId} from '../../../services/flags/flags';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {RenderPreferences} from '../../../api/diff';
 import {
@@ -211,6 +212,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly flagsService = appContext.flagsService;
+
   private readonly commentsService = appContext.commentsService;
 
   readonly storage = appContext.storageService;
@@ -357,7 +360,9 @@
   _getLayers(diff?: DiffInfo) {
     if (!diff) return [];
     const layers = [];
-    layers.push(new TokenHighlightLayer(this));
+    if (this.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)) {
+      layers.push(new TokenHighlightLayer(this));
+    }
     layers.push(this.syntaxLayer);
     return layers;
   }
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 21f3aa4..ef5fde2 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,6 +25,7 @@
  */
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
+  TOKEN_HIGHLIGHTING = 'UiFeature__token_highlighting',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   NEW_REPLY_DIALOG = 'UiFeature__new_reply_dialog',
   SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
index 00ebc63..a3b694f 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -22,6 +22,7 @@
   if(runUnderBazel) {
     // Run under bazel
     return [
+      `external/plugins_npm/node_modules`,
       `external/ui_npm/node_modules`,
       `external/ui_dev_npm/node_modules`
     ];
@@ -58,11 +59,11 @@
 }
 
 module.exports = function(config) {
-  const localDirName = path.resolve(__dirname, '../.ts-out/polygerrit-ui/app');
-  const rootDir = runUnderBazel ?
-      'polygerrit-ui/app/_pg_with_tests_out/' : localDirName + '/';
-  const testFilesLocationPattern =
-      `${rootDir}**/!(template_test_srcs)/`;
+  let root = config.root;
+  if (!root) {
+    console.warn(`--root argument not set. Falling back to __dirname.`)
+    root = path.resolve(__dirname) + '/';
+  }
   // Use --test-files to specify pattern for a test files.
   // It can be just a file name, without a path:
   // --test-files async-foreach-behavior_test.js
@@ -83,7 +84,9 @@
   } else {
     filePattern = '*_test.js';
   }
-  const testFilesPattern = testFilesLocationPattern + filePattern;
+  const testFilesPattern = root + '**/' + filePattern;
+
+  console.info(`Karma test file pattern: ${testFilesPattern}`)
   // Special patch for grep parameters (see details in the grep-patch-karam.js)
   const additionalFiles = runUnderBazel ? [] : ['polygerrit-ui/grep-patch-karma.js'];
   config.set({
diff --git a/polygerrit-ui/karma_test.sh b/polygerrit-ui/karma_test.sh
index 5fab442..940b969 100755
--- a/polygerrit-ui/karma_test.sh
+++ b/polygerrit-ui/karma_test.sh
@@ -1,4 +1,6 @@
 #!/bin/bash
 
 set -euo pipefail
-./$1 start $2 --single-run
+./$1 start $2 \
+  --root 'polygerrit-ui/app/_pg_with_tests_out/**/' \
+  --test-files '*_test.js'
diff --git a/resources/com/google/gerrit/server/mail/Merged.soy b/resources/com/google/gerrit/server/mail/Merged.soy
index e268a31..b8a19fc 100644
--- a/resources/com/google/gerrit/server/mail/Merged.soy
+++ b/resources/com/google/gerrit/server/mail/Merged.soy
@@ -30,7 +30,7 @@
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
 
-  {$email.stickyApprovalDiff}
+  {if $email.stickyApprovalDiff} ( {$email.stickyApprovalDiff} ){/if}
 
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
diff --git a/resources/com/google/gerrit/server/mail/MergedHtml.soy b/resources/com/google/gerrit/server/mail/MergedHtml.soy
index d2f7bfd..ac4afb3 100644
--- a/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -32,9 +32,11 @@
     </p>
   {/if}
 
-  {call mailTemplate.UnifiedDiff}
-    {param diffLines: $email.stickyApprovalDiffHtml /}
-  {/call}
+  {if $email.stickyApprovalDiffHtml}
+    {call mailTemplate.UnifiedDiff}
+      {param diffLines: $email.stickyApprovalDiffHtml /}
+    {/call}
+  {/if}
 
   <div style="white-space:pre-wrap">{$email.approvals}</div>
 
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index eedf0a1..c8d6e4b 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -142,3 +142,36 @@
             "zip -Drq $$ROOT/$@ -g .",
         ]),
     )
+
+def karma_test(name, srcs, data):
+    """Creates a Karma test target.
+
+    It can be used both for the main Gerrit js bundle, but also for plugins. So
+    it should be extremely easy to add Karma test capabilities for new plugins.
+
+    We are sharing one karma.conf.js file. If you want to customize that, then
+    consider using command line arguments that the config file can process, see
+    the `root` argument for an example.
+
+    Args:
+      name: The name of the test rule.
+      srcs: The shell script to invoke, where you can set command line
+        arguments for Karma and its config.
+      data: The bundle of JavaScript files with the tests included.
+    """
+
+    native.sh_test(
+        name = name,
+        size = "enormous",
+        srcs = srcs,
+        args = [
+            "$(location //polygerrit-ui:karma_bin)",
+            "$(location //polygerrit-ui:karma.conf.js)",
+        ],
+        data = data + [
+            "//polygerrit-ui:karma_bin",
+            "//polygerrit-ui:karma.conf.js",
+        ],
+        # Should not run sandboxed.
+        tags = ["karma", "local", "manual"],
+    )
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
index b32e2bc..eb4d37a 100644
--- a/tools/js/eslint.bzl
+++ b/tools/js/eslint.bzl
@@ -16,6 +16,34 @@
 
 load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test")
 
+def plugin_eslint():
+    """ Convenience wrapper macro of eslint() for Gerrit js plugins
+
+    Args:
+        name: name of the rule
+    """
+    eslint(
+        name = "lint",
+        srcs = native.glob(["**/*.ts"]),
+        config = ".eslintrc.js",
+        data = [
+            "tsconfig.json",
+            "//plugins:.eslintrc.js",
+            "//plugins:.prettierrc.js",
+            "//plugins:tsconfig-plugins-base.json",
+        ],
+        extensions = [".ts"],
+        ignore = "//plugins:.eslintignore",
+        plugins = [
+            "@npm//eslint-config-google",
+            "@npm//eslint-plugin-html",
+            "@npm//eslint-plugin-import",
+            "@npm//eslint-plugin-jsdoc",
+            "@npm//eslint-plugin-prettier",
+            "@npm//gts",
+        ],
+    )
+
 def eslint(name, plugins, srcs, config, ignore, extensions = [".js"], data = []):
     """ Macro to define eslint rules for files.
 
@@ -87,7 +115,7 @@
             "*_test_require_patch.js",
             "--ignore-pattern",
             "*_test_loader.js",
-            "./", # Relative to the config file location
+            "./",  # Relative to the config file location
         ],
         # Should not run sandboxed.
         tags = [