Simple Task UI

Display a list of tasks in the 'FAIL', 'READY', or 'INVALID' states. No
hierarchy between tasks and subtasks is displayed, just a flat list. Uses the
task name or a hint value when available.

The UI hides itself when there are no tasks to list.

Include a copy of .eslintrc.json from core for linting/formatting js and
add package.json to make running the linter easy.

Change-Id: I64b2cd4ca944e89f5bb915caadf10576cc16550b
diff --git a/.gitignore b/.gitignore
index deb830a..da5cb24 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,5 +7,6 @@
 /.settings/org.eclipse.m2e.core.prefs
 /.settings/org.eclipse.core.resources.prefs
 /.settings/org.eclipse.jdt.core.prefs
-
+/package-lock.json
 /task.iml
+/node_modules/
diff --git a/BUILD b/BUILD
index 6420f64..3f724db 100644
--- a/BUILD
+++ b/BUILD
@@ -1,4 +1,10 @@
-load("//tools/bzl:plugin.bzl", "gerrit_plugin")
+load(
+    "//tools/bzl:plugin.bzl",
+    "PLUGIN_DEPS",
+    "gerrit_plugin",
+)
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:js.bzl", "polygerrit_plugin")
 
 gerrit_plugin(
     name = "task",
@@ -12,5 +18,27 @@
         "Gerrit-SshModule: com.googlesource.gerrit.plugins.task.Modules$SshModule",
         "Gerrit-HttpModule: com.googlesource.gerrit.plugins.task.Modules$HttpModule",
     ],
+    resource_jars = [":gr-task-plugin-static"],
     resources = glob(["src/main/resources/**/*"]),
 )
+
+genrule2(
+    name = "gr-task-plugin-static",
+    srcs = [":gr-task-plugin"],
+    outs = ["gr-task-plugin-static.jar"],
+    cmd = " && ".join([
+        "mkdir $$TMP/static",
+        "cp -r $(locations :gr-task-plugin) $$TMP/static",
+        "cd $$TMP",
+        "zip -Drq $$ROOT/$@ -g .",
+    ]),
+)
+
+polygerrit_plugin(
+    name = "gr-task-plugin",
+    srcs = glob([
+        "gr-task-plugin/*.html",
+        "gr-task-plugin/*.js",
+    ]),
+    app = "plugin.html",
+)
diff --git a/WORKSPACE b/WORKSPACE
index 8f3001b..5d2fa3e 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -7,6 +7,32 @@
     #local_path = "/home/<user>/projects/bazlets",
 )
 
+# Polymer dependencies
+load(
+    "@com_googlesource_gerrit_bazlets//:gerrit_polymer.bzl",
+    "gerrit_polymer",
+)
+
+gerrit_polymer()
+
+# Load closure compiler with transitive dependencies
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_repositories")
+
+closure_repositories()
+
+# Load Gerrit npm_binary toolchain
+load("@com_googlesource_gerrit_bazlets//tools:js.bzl", "GERRIT", "npm_binary")
+
+npm_binary(
+    name = "polymer-bundler",
+    repository = GERRIT,
+)
+
+npm_binary(
+    name = "crisper",
+    repository = GERRIT,
+)
+
 # Release Plugin API
 load(
     "@com_googlesource_gerrit_bazlets//:gerrit_api.bzl",
diff --git a/gr-task-plugin/.eslintrc.json b/gr-task-plugin/.eslintrc.json
new file mode 100644
index 0000000..b5d3dae
--- /dev/null
+++ b/gr-task-plugin/.eslintrc.json
@@ -0,0 +1,80 @@
+{
+  "extends": ["eslint:recommended", "google"],
+  "parserOptions": {
+    "ecmaVersion": 8
+  },
+  "env": {
+    "browser": true,
+    "es6": true
+  },
+  "globals": {
+    "__dirname": false,
+    "app": false,
+    "page": false,
+    "Polymer": false,
+    "process": false,
+    "require": false,
+    "Gerrit": false,
+    "Promise": false,
+    "assert": false,
+    "test": false,
+    "flushAsynchronousOperations": false
+  },
+  "rules": {
+    "arrow-parens": ["error", "as-needed"],
+    "block-spacing": ["error", "always"],
+    "brace-style": ["error", "1tbs", { "allowSingleLine": true }],
+    "camelcase": "off",
+    "comma-dangle": ["error", "always-multiline"],
+    "eol-last": "off",
+    "indent": "off",
+    "indent-legacy": ["error", 2, {
+      "MemberExpression": 2,
+      "FunctionDeclaration": {"body": 1, "parameters": 2},
+      "FunctionExpression": {"body": 1, "parameters": 2},
+      "CallExpression": {"arguments": 2},
+      "ArrayExpression": 1,
+      "ObjectExpression": 1,
+      "SwitchCase": 1
+    }],
+    "keyword-spacing": ["error", { "after": true, "before": true }],
+    "max-len": [
+      "error",
+      80,
+      2,
+      {"ignoreComments": true}
+    ],
+    "new-cap": ["error", { "capIsNewExceptions": ["Polymer"] }],
+    "no-console": "off",
+    "no-restricted-syntax": [
+      "error",
+      {
+        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
+        "message": "Remove test.only."
+      },
+      {
+        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
+        "message": "Remove suite.only."
+      }
+    ],
+    "no-undef": "off",
+    "no-useless-escape": "off",
+    "no-var": "error",
+    "object-shorthand": ["error", "always"],
+    "prefer-arrow-callback": "error",
+    "prefer-const": "error",
+    "prefer-promise-reject-errors": "off",
+    "prefer-spread": "error",
+    "quote-props": ["error", "consistent-as-needed"],
+    "require-jsdoc": "off",
+    "semi": [2, "always"],
+    "template-curly-spacing": "error",
+    "valid-jsdoc": "off"
+  },
+  "plugins": [
+    "html"
+  ],
+  "settings": {
+    "html/report-bad-indent": "error"
+  }
+}
diff --git a/gr-task-plugin/gr-task-plugin.html b/gr-task-plugin/gr-task-plugin.html
new file mode 100644
index 0000000..67c1319
--- /dev/null
+++ b/gr-task-plugin/gr-task-plugin.html
@@ -0,0 +1,55 @@
+<!--
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<dom-module id="gr-task-plugin">
+  <template>
+      <style>
+        ul { padding-left: 30px; }
+        h3 { padding-left: 5px; }
+      </style>
+
+      <div id="tasks" hidden$="[[!_tasks.length]]">
+        <h3>Tasks: (Needs + Blocked)</h3>
+        <ul>
+          <gr-task-plugin-tasks tasks="[[_tasks]]"></gr-task-plugin-tasks>
+        </ul>
+      </div>
+  </template>
+  <script src="gr-task-plugin.js"></script>
+</dom-module>
+
+<dom-module id="gr-task-plugin-tasks">
+  <template>
+    <template is="dom-repeat" as="task" items="[[tasks]]">
+      <template is="dom-if" if="[[task.message]]">
+        <li>[[task.message]]</li>
+      </template>
+      <gr-task-plugin-tasks tasks="[[task.sub_tasks]]"></gr-task-plugin-tasks>
+    </template>
+  </template>
+  <script>
+      Polymer({
+        is: 'gr-task-plugin-tasks',
+        properties: {
+          tasks: {
+            type: Array,
+            notify: true,
+            value() { return []; },
+          },
+        },
+      });
+  </script>
+</dom-module>
diff --git a/gr-task-plugin/gr-task-plugin.js b/gr-task-plugin/gr-task-plugin.js
new file mode 100644
index 0000000..9fca732
--- /dev/null
+++ b/gr-task-plugin/gr-task-plugin.js
@@ -0,0 +1,89 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  const Defs = {};
+  /**
+   * @typedef {{
+   *  message: string,
+   *  sub_tasks: Array<Defs.Task>,
+   *  hint: ?string,
+   *  name: string,
+   *  status: string
+   * }}
+   */
+  Defs.Task;
+
+  Polymer({
+    is: 'gr-task-plugin',
+    properties: {
+      change: {
+        type: Object,
+      },
+
+      // @type {Array<Defs.Task>}
+      _tasks: {
+        type: Array,
+        notify: true,
+        value() { return []; },
+      },
+    },
+
+    attached() {
+      this._getTasks();
+    },
+
+    _getTasks() {
+      const endpoint =
+          `/changes/?q=change:${this.change._number}&--task--applicable`;
+
+      return this.plugin.restApi().get(endpoint).then(response => {
+        if (response && response.length === 1) {
+          const cinfo = response[0];
+          if (cinfo.plugins) {
+            const taskPluginInfo = cinfo.plugins.find(
+                pluginInfo => pluginInfo.name === 'task');
+
+            if (taskPluginInfo) {
+              this._tasks = this._addTasks(taskPluginInfo.roots);
+            }
+          }
+        }
+      });
+    },
+
+    _getTaskDescription(task) {
+      return task.hint || task.name;
+    },
+
+    _computeMessage(task) {
+      switch (task.status) {
+        case 'FAIL':
+        case 'READY':
+        case 'INVALID':
+          return this._getTaskDescription(task);
+      }
+    },
+
+    _addTasks(tasks) { // rename to process, remove DOM bits
+      if (!tasks) return [];
+      tasks.forEach(task => {
+        task.message = this._computeMessage(task);
+        this._addTasks(task.sub_tasks);
+      });
+      return tasks;
+    },
+  });
+})();
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4fe838a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+  "name": "task",
+  "version": "2.16.13-SNAPSHOT",
+  "description": "Task Plugin",
+  "dependencies": {},
+  "devDependencies": {
+    "eslint": "^6.6.0",
+    "eslint-config-google": "^0.13.0",
+    "eslint-plugin-html": "^6.0.0"
+  },
+  "scripts": {
+    "eslint": "./node_modules/eslint/bin/eslint.js --ext .html,.js gr-task-plugin || exit 0",
+    "eslintfix": "./node_modules/eslint/bin/eslint.js --fix --ext .html,.js gr-task-plugin || exit 0"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://gerrit.googlesource.com/plugins/task"
+  },
+  "author": "",
+  "license": "Apache-2.0"
+}
diff --git a/plugin.html b/plugin.html
new file mode 100644
index 0000000..9d3144c
--- /dev/null
+++ b/plugin.html
@@ -0,0 +1,28 @@
+<!--
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="./gr-task-plugin/gr-task-plugin.html">
+
+<dom-module id="task-plugin">
+  <script>
+    if (window.Polymer) {
+      Gerrit.install(function(plugin) {
+          plugin.registerCustomComponent(
+              'change-view-integration', 'gr-task-plugin');
+      });
+    }
+  </script>
+</dom-module>
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java b/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java
index 05c6849..764b17e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java
@@ -15,6 +15,9 @@
 package com.googlesource.gerrit.plugins.task;
 
 import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.webui.JavaScriptPlugin;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor.ChangeAttributeFactory;
 import com.google.gerrit.server.restapi.change.QueryChanges;
@@ -47,6 +50,7 @@
     @Override
     protected void configure() {
       bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
+      DynamicSet.bind(binder(), WebUiPlugin.class).toInstance(new JavaScriptPlugin("gr-task-plugin.html"));
     }
   }
 
diff --git a/tools/bzl/genrule2.bzl b/tools/bzl/genrule2.bzl
new file mode 100644
index 0000000..e223dcd
--- /dev/null
+++ b/tools/bzl/genrule2.bzl
@@ -0,0 +1,2 @@
+load("@com_googlesource_gerrit_bazlets//tools:genrule2.bzl", _genrule2 = "genrule2")
+genrule2 = _genrule2
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
new file mode 100644
index 0000000..ec1e08a
--- /dev/null
+++ b/tools/bzl/js.bzl
@@ -0,0 +1,2 @@
+load("@com_googlesource_gerrit_bazlets//tools:js.bzl", _polygerrit_plugin = "polygerrit_plugin")
+polygerrit_plugin = _polygerrit_plugin