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"
+ ]
+}