Convert automerger to TypeScript and add tests

Google-Bug-Id: b/200047405
Change-Id: Ia24c1b7ac489217bc9de9db5b3e715461c445035
diff --git a/BUILD b/BUILD
index cfd1fbd..4a1fd1a 100644
--- a/BUILD
+++ b/BUILD
@@ -10,6 +10,7 @@
         "Implementation-Title: Automerger plugin",
         "Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/automerger",
     ],
+    resource_jars = ["//plugins/automerger/web:automerger"],
     resources = glob(["src/main/resources/**/*"]),
     deps = [
         "@re2j//jar",
diff --git a/src/main/resources/static/automerger.js b/src/main/resources/static/automerger.js
deleted file mode 100644
index 67ec7b8..0000000
--- a/src/main/resources/static/automerger.js
+++ /dev/null
@@ -1,105 +0,0 @@
-// Copyright (C) 2016 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.
-
-var currentChange;
-var downstreamConfigMap;
-Gerrit.install(function(self) {
-    const restApi = self.restApi();
-    const changeActions = self.changeActions();
-
-    function onAutomergeChange(c) {
-        addCheckboxes(c, downstreamConfigMap);
-    }
-
-    function addCheckboxes(c, downstreamConfigMap) {
-        var branchToCheckbox = {};
-        var downstreamConfigBranches = Object.keys(downstreamConfigMap);
-        // Initialize checkboxes for each downstream branch
-        downstreamConfigBranches.forEach(function(branch) {
-            var checkbox = c.checkbox();
-            if (downstreamConfigMap[branch])
-                checkbox.checked = true;
-            branchToCheckbox[branch] = c.label(checkbox, branch);
-        });
-
-        // Add checkboxes to box for each downstream branch
-        var checkboxes = [];
-        Object.keys(branchToCheckbox).forEach(function(branch) {
-            checkboxes.push(branchToCheckbox[branch])
-            checkboxes.push(c.br());
-        });
-        // Create actual merge button
-        var b = createMergeButton(c, branchToCheckbox);
-        var popupElements = checkboxes.concat(b);
-        const div = document.createElement('div');
-        for (const el of popupElements) {
-          div.appendChild(el);
-        }
-        c.popup(div);
-        return branchToCheckbox;
-    }
-
-    function createMergeButton(c, branchToCheckbox) {
-        return c.button('Merge', {onclick: function(e){
-            var branchMap = {};
-            Object.keys(branchToCheckbox).forEach(function(key){
-                branchMap[key] = branchToCheckbox[key].firstChild.checked;
-            });
-            // gerrit converts to camelcase on the java end
-            c.call({'branch_map': branchMap},
-                function(r){ c.refresh(); });
-            e.currentTarget.setAttribute("disabled", true);
-        }});
-    }
-
-    function styleRelatedChanges() {
-        document.querySelectorAll('[data-branch]').forEach(function(relChange) {
-            var relatedBranch = relChange.dataset.branch;
-            if (relatedBranch == currentChange.branch) {
-                relChange.style.fontWeight = 'bold';
-            } else {
-                relChange.style.fontWeight = '';
-            }
-            if (relChange.innerText.includes('[skipped')) {
-                relChange.parentNode.style.backgroundColor = 'lightGray';
-            }
-        })
-    }
-
-    function getDownstreamConfigMap() {
-        var changeId = currentChange._number;
-        var revisionId = currentChange.current_revision;
-        var url = `/changes/${changeId}/revisions/${revisionId}` +
-                   `/automerger~config-downstream`;
-        restApi.post(url, {'subject': currentChange.subject})
-            .then((resp) => {
-                downstreamConfigMap = resp;
-                styleRelatedChanges();
-            });
-    }
-
-    function onShowChange(e, revision) {
-        currentChange = e;
-        getDownstreamConfigMap();
-        const detail = changeActions.getActionDetails('automerge-change');
-        if (detail) {
-            changeActions.addTapListener(detail.__key, () => {
-              onAutomergeChange(
-                new GrPluginActionContext(self, detail, e, revision)
-              );
-            });
-        }
-    }
-    self.on('showchange', onShowChange);
-});
diff --git a/web/.eslintrc.js b/web/.eslintrc.js
new file mode 100644
index 0000000..5ea555c
--- /dev/null
+++ b/web/.eslintrc.js
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright (C) 2021 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.
+ */
+__plugindir = 'automerger/web';
+module.exports = {
+  extends: '../../.eslintrc.js',
+};
diff --git a/web/BUILD b/web/BUILD
new file mode 100644
index 0000000..0f8e233
--- /dev/null
+++ b/web/BUILD
@@ -0,0 +1,60 @@
+load("//tools/js:eslint.bzl", "plugin_eslint")
+load("//tools/bzl:js.bzl", "gerrit_js_bundle", "karma_test")
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
+
+package_group(
+    name = "visibility",
+    packages = ["//plugins/automerger/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+ts_config(
+    name = "tsconfig",
+    src = "tsconfig.json",
+    deps = [
+        "//plugins:tsconfig-plugins-base.json",
+    ],
+)
+
+ts_project(
+    name = "automerger-ts",
+    srcs = glob(
+        ["**/*.ts"],
+        exclude = ["**/*test*"],
+    ),
+    incremental = True,
+    out_dir = "_bazel_ts_out",
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":tsconfig",
+    deps = [
+        "@plugins_npm//@gerritcodereview/typescript-api",
+    ],
+)
+
+ts_project(
+    name = "automerger-ts-tests",
+    srcs = glob(["**/*.ts"]),
+    incremental = True,
+    out_dir = "_bazel_ts_out_tests",
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":tsconfig",
+    deps = [
+        "@plugins_npm//:node_modules",
+        "@ui_dev_npm//:node_modules",
+    ],
+)
+
+gerrit_js_bundle(
+    name = "automerger",
+    srcs = [":automerger-ts"],
+    entry_point = "_bazel_ts_out/plugin.js",
+)
+
+karma_test(
+    name = "karma_test",
+    srcs = ["karma_test.sh"],
+    data = [":automerger-ts-tests"],
+)
+
+plugin_eslint()
diff --git a/web/automerger.ts b/web/automerger.ts
new file mode 100644
index 0000000..f34f5d9
--- /dev/null
+++ b/web/automerger.ts
@@ -0,0 +1,176 @@
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
+import {
+  ActionInfo,
+  ChangeInfo,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {ChangeActionsPluginApi} from '@gerritcodereview/typescript-api/change-actions';
+import {PopupPluginApi} from '@gerritcodereview/typescript-api/popup';
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {RequestPayload} from '@gerritcodereview/typescript-api/rest';
+
+// export for testing only
+export type ConfigMap = {[branch: string]: boolean};
+
+export interface UIActionInfo extends ActionInfo {
+  __key: string;
+  __url?: string;
+}
+
+interface PopupPluginApiExtended extends PopupPluginApi {
+  // TODO: Remove this reference to a private method. This can break any time.
+  _getElement: () => HTMLElement;
+}
+
+export class Automerger {
+  private change?: ChangeInfo;
+
+  private action?: UIActionInfo;
+
+  private downstreamConfigMap: ConfigMap = {};
+
+  readonly restApi: RestPluginApi;
+
+  readonly actionsApi: ChangeActionsPluginApi;
+
+  constructor(readonly plugin: PluginApi) {
+    this.restApi = plugin.restApi();
+    this.actionsApi = plugin.changeActions();
+  }
+
+  private callAction(payload: RequestPayload, onSuccess: () => void) {
+    if (!this.action?.method) return;
+    if (!this.action?.__url) return;
+    this.plugin
+      .restApi()
+      .send(this.action.method, this.action.__url, payload)
+      .then(onSuccess)
+      .catch((error: unknown) => {
+        document.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: `Plugin network error: ${error}`},
+          })
+        );
+      });
+  }
+
+  private onAutomergeChange() {
+    // Create checkboxes for each downstream branch.
+    const branchToCheckbox: {[branch: string]: HTMLElement} = {};
+    const downstreamConfigBranches = Object.keys(this.downstreamConfigMap);
+    downstreamConfigBranches.forEach(branch => {
+      const checkbox = document.createElement('input');
+      checkbox.type = 'checkbox';
+      if (this.downstreamConfigMap[branch]) checkbox.checked = true;
+      const label = document.createElement('gr-label');
+      label.appendChild(document.createTextNode(branch));
+      const div = document.createElement('div');
+      div.appendChild(checkbox);
+      div.appendChild(label);
+      branchToCheckbox[branch] = div;
+    });
+
+    // Create popup content.
+    const popupContent = document.createElement('div');
+    for (const branch of Object.keys(branchToCheckbox)) {
+      popupContent.appendChild(branchToCheckbox[branch]);
+      popupContent.appendChild(document.createElement('br'));
+    }
+    popupContent.appendChild(this.createMergeButton(branchToCheckbox));
+
+    this.plugin.popup().then((popApi: PopupPluginApi) => {
+      const popupEl = (popApi as PopupPluginApiExtended)._getElement();
+      if (!popupEl) throw new Error('Popup element not found');
+      popupEl.appendChild(popupContent);
+    });
+  }
+
+  private createMergeButton(branchToCheckbox: {[branch: string]: HTMLElement}) {
+    const onClick = (e: Event) => {
+      const branchMap: {[branch: string]: boolean} = {};
+      for (const branch of Object.keys(branchToCheckbox)) {
+        branchMap[branch] =
+          (branchToCheckbox[branch].firstChild as HTMLInputElement | undefined)
+            ?.checked ?? false;
+      }
+      this.callAction({branch_map: branchMap}, () => {
+        this.windowReload();
+      });
+      const target = e.currentTarget;
+      if (target && target instanceof Element) {
+        target.setAttribute('disabled', 'true');
+      }
+    };
+    const button = document.createElement('gr-button');
+    button.appendChild(document.createTextNode('Merge'));
+    button.addEventListener('click', onClick);
+    return button;
+  }
+
+  // public for testing only
+  windowReload() {
+    window.location.reload();
+  }
+
+  private styleRelatedChanges() {
+    document.querySelectorAll('[data-branch]').forEach(relChange => {
+      if (!(relChange instanceof HTMLElement)) return;
+      if (!this.change) return;
+      const relatedBranch = relChange.dataset.branch;
+      if (relatedBranch === this.change.branch) {
+        relChange.style.fontWeight = 'bold';
+      } else {
+        relChange.style.fontWeight = '';
+      }
+      if (relChange.innerText.includes('[skipped')) {
+        const parent = relChange.parentNode;
+        if (parent && parent instanceof HTMLElement) {
+          parent.style.backgroundColor = 'lightGray';
+        }
+      }
+    });
+  }
+
+  private getDownstreamConfigMap() {
+    const change = this.change;
+    if (!change) return;
+    const changeId = change._number;
+    const revisionId = change.current_revision;
+    const url =
+      `/changes/${changeId}/revisions/${revisionId}` +
+      '/automerger~config-downstream';
+    this.restApi.post<ConfigMap>(url, {subject: change.subject}).then(resp => {
+      this.downstreamConfigMap = resp;
+      this.styleRelatedChanges();
+    });
+  }
+
+  onShowChange(change: ChangeInfo) {
+    this.change = change;
+    this.action = this.actionsApi.getActionDetails(
+      'automerge-change'
+    ) as UIActionInfo;
+    this.downstreamConfigMap = {};
+    this.getDownstreamConfigMap();
+    if (this.action) {
+      this.actionsApi.addTapListener(this.action.__key, () => {
+        this.onAutomergeChange();
+      });
+    }
+  }
+}
diff --git a/web/automerger_test.ts b/web/automerger_test.ts
new file mode 100644
index 0000000..6e55bdb
--- /dev/null
+++ b/web/automerger_test.ts
@@ -0,0 +1,138 @@
+/**
+ * @license
+ * Copyright (C) 2021 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.
+ */
+import './test/test-setup';
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {
+  ChangeInfo,
+  ChangeStatus,
+  HttpMethod,
+  BranchName,
+  NumericChangeId,
+  RepoName,
+  ChangeId,
+  Timestamp,
+  ChangeInfoId,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {Automerger, ConfigMap, UIActionInfo} from './automerger';
+import {queryAll, queryAndAssert, waitUntil} from './test/test-util';
+
+const change: ChangeInfo = {
+  _number: 123 as NumericChangeId,
+  branch: 'test-branch' as BranchName,
+  change_id: 'I123456789abcdef932b39a2a809879fb163ccb41' as ChangeId,
+  created: '2021-09-01 12:12:12.000000000' as Timestamp,
+  deletions: 0,
+  id: 'test-repo~test-branch~I123456789abcdef932b39a2a809879fb163ccb41' as ChangeInfoId,
+  insertions: 0,
+  owner: {},
+  project: 'test-repo' as RepoName,
+  reviewers: {},
+  status: ChangeStatus.NEW,
+  subject: 'test-subject',
+  updated: '2021-09-02 12:12:12.000000000' as Timestamp,
+};
+
+const configMap: ConfigMap = {
+  'branch-1': true,
+  'branch-2': true,
+  'branch-3': false,
+};
+
+suite('automerger tests', () => {
+  let automerger: Automerger;
+  let callback: (() => void) | undefined;
+  let sendStub: sinon.SinonStub;
+  let postStub: sinon.SinonStub;
+  let reloadStub: sinon.SinonStub;
+  let popup: HTMLElement | undefined;
+
+  const actionInfo: UIActionInfo = {
+    __key: 'test-key',
+    __url: 'http://test-url',
+    enabled: true,
+    label: 'test-label',
+    method: HttpMethod.GET,
+    title: 'test-title',
+  };
+
+  setup(async () => {
+    sendStub = sinon.stub();
+    sendStub.returns(Promise.resolve({}));
+    postStub = sinon.stub();
+    postStub.returns(Promise.resolve(configMap));
+    const fakePlugin = {
+      popup: () =>
+        Promise.resolve({
+          _getElement: () => {
+            popup = document.createElement('div');
+            return popup;
+          },
+        }),
+      changeActions: () => {
+        return {
+          getActionDetails: () => actionInfo,
+          addTapListener: (_: string, cb: () => void) => (callback = cb),
+        };
+      },
+      restApi: () => {
+        return {
+          send: sendStub,
+          post: postStub,
+        };
+      },
+    } as unknown as PluginApi;
+    automerger = new Automerger(fakePlugin);
+    reloadStub = sinon.stub(automerger, 'windowReload');
+  });
+
+  teardown(() => {
+    if (popup) popup.remove();
+    popup = undefined;
+    callback = undefined;
+  });
+
+  test('callback set, popup created', async () => {
+    assert.isNotOk(callback);
+    assert.isNotOk(popup);
+
+    automerger.onShowChange(change);
+    assert.isOk(callback, 'callback expected to be set');
+    if (callback) callback();
+    await waitUntil(() => popup !== undefined);
+    assert.equal(popup?.childElementCount, 1);
+  });
+
+  test('popup contains 3 checkboxes', async () => {
+    automerger.onShowChange(change);
+    await waitUntil(() => postStub.called);
+    if (callback) callback();
+    await waitUntil(() => popup !== undefined);
+    const checkboxes = queryAll(popup!, 'input');
+    assert.equal(checkboxes?.length, 3);
+  });
+
+  test('popup contains button, which triggers send', async () => {
+    automerger.onShowChange(change);
+    await waitUntil(() => postStub.called);
+    if (callback) callback();
+    await waitUntil(() => popup !== undefined);
+    const button = queryAndAssert<HTMLElement>(popup, 'gr-button');
+    button.click();
+    assert.isTrue(sendStub.called, 'expected send() to be called');
+    await waitUntil(() => reloadStub.called);
+  });
+});
diff --git a/web/karma_test.sh b/web/karma_test.sh
new file mode 100755
index 0000000..9597611
--- /dev/null
+++ b/web/karma_test.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+set -euo pipefail
+./$1 start $2 --single-run \
+  --root 'plugins/automerger/web/_bazel_ts_out_tests/' \
+  --test-files '*_test.js'
diff --git a/web/plugin.ts b/web/plugin.ts
new file mode 100644
index 0000000..e926a35
--- /dev/null
+++ b/web/plugin.ts
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * Copyright (C) 2021 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.
+ */
+import '@gerritcodereview/typescript-api/gerrit';
+import {EventType} from '@gerritcodereview/typescript-api/plugin';
+import {ChangeInfo} from '@gerritcodereview/typescript-api/rest-api';
+import {Automerger} from './automerger';
+
+window.Gerrit.install(plugin => {
+  const automerger = new Automerger(plugin);
+  plugin.on(EventType.SHOW_CHANGE, (change: ChangeInfo) =>
+    automerger.onShowChange(change)
+  );
+});
diff --git a/web/test/test-setup.ts b/web/test/test-setup.ts
new file mode 100644
index 0000000..150be02
--- /dev/null
+++ b/web/test/test-setup.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright (C) 2021 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.
+ */
+import 'chai/chai';
+
+declare global {
+  interface Window {
+    assert: typeof chai.assert;
+    expect: typeof chai.expect;
+    sinon: typeof sinon;
+  }
+  let assert: typeof chai.assert;
+  let expect: typeof chai.expect;
+  let sinon: typeof sinon;
+}
+window.assert = chai.assert;
+window.expect = chai.expect;
+window.sinon = sinon;
diff --git a/web/test/test-util.ts b/web/test/test-util.ts
new file mode 100644
index 0000000..ca61e2c
--- /dev/null
+++ b/web/test/test-util.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2021 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.
+ */
+export function queryAll<E extends Element = Element>(
+  el: Element,
+  selector: string
+): NodeListOf<E> {
+  if (!el) throw new Error('element not defined');
+  const root = el.shadowRoot ?? el;
+  return root.querySelectorAll<E>(selector);
+}
+
+export function query<E extends Element = Element>(
+  el: Element | undefined,
+  selector: string
+): E | undefined {
+  if (!el) return undefined;
+  const root = el.shadowRoot ?? el;
+  return root.querySelector<E>(selector) ?? undefined;
+}
+
+export function queryAndAssert<E extends Element = Element>(
+  el: Element | undefined,
+  selector: string
+): E {
+  const found = query<E>(el, selector);
+  if (!found) throw new Error(`selector '${selector}' did not match anything'`);
+  return found;
+}
+
+export function waitUntil(
+  predicate: () => boolean,
+  maxMillis = 100
+): Promise<void> {
+  const start = Date.now();
+  let sleep = 1;
+  return new Promise((resolve, reject) => {
+    const waiter = () => {
+      if (predicate()) {
+        return resolve();
+      }
+      if (Date.now() - start >= maxMillis) {
+        return reject(new Error('Took to long to waitUntil'));
+      }
+      setTimeout(waiter, sleep);
+      sleep *= 2;
+    };
+    waiter();
+  });
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..dc7d0ac
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig-plugins-base.json",
+  "compilerOptions": {
+    "outDir": "../../../.ts-out/plugins/automerger", /* overridden by bazel */
+  },
+  "include": [
+    "**/*.ts"
+  ]
+}