Migrate UI to a PolyGerrit repo-command web component
The plugin UI still used the GWT-era screen API: the static
JavaScript called self.onAction('project', 'parent', ...) with the
popup action context. PolyGerrit removed onAction() long ago, so the
plugin loaded but never showed any UI.
Replace it with a Lit web component in TypeScript, built with the
same toolchain as the delete-project plugin: a web/ directory with a
ts_project compiled against the shared plugins tsconfig base, rolled
up by gerrit_js_bundle into static/reparent-project.js and attached
to the plugin jar as a resource jar. Because the bundle keeps the
old file name, HttpModule and its JavaScriptPlugin binding are
unchanged.
The component registers at the repo-command endpoint. It renders
only when the server-side UiAction is present in config.actions,
which preserves the permission-based visibility of the old popup:
the action is exposed only to users passing the plugin's
canReparent() check. The dialog offers the new parent project and an
optional commit message, and issues the same
PUT /projects/{name}/reparent~parent request as before.
Change-Id: I7d94e2c8f1b60a5372840df9ce1b7a46e8d05c31
diff --git a/BUILD b/BUILD
index cddd4e8..9c0da2c 100644
--- a/BUILD
+++ b/BUILD
@@ -8,6 +8,7 @@
"Gerrit-HttpModule: com.googlesource.gerrit.plugins.reparent.HttpModule",
],
plugin = "reparent",
+ resource_jars = ["//plugins/reparent/web:reparent-project"],
resources = glob(["src/main/resources/**/*"]),
)
diff --git a/src/main/resources/static/reparent-project.js b/src/main/resources/static/reparent-project.js
deleted file mode 100644
index a91820f..0000000
--- a/src/main/resources/static/reparent-project.js
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2015 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.
-
-Gerrit.install(function(self) {
- function onReparentProject(c) {
- var p = c.textfield();
- var m = c.textarea();
- var b = c.button('Reparent',
- {onclick: function() {
- c.call(
- {parent: p.value, commit_message: m.value},
- function(r) {
- c.hide();
- window.alert('The project: "'
- + c.project
- + '" was reparented.');
- });
- }});
- c.popup(c.div(
- c.msg('Reparent project "'
- + c.project
- + '"'),
- c.br(),
- c.prependLabel('New Parent Project: ', p),
- c.br(),
- c.prependLabel('Commit Message: ', m),
- c.br(),
- b));
- }
- self.onAction('project', 'parent', onReparentProject);
- });
diff --git a/web/BUILD b/web/BUILD
new file mode 100644
index 0000000..a19f2da
--- /dev/null
+++ b/web/BUILD
@@ -0,0 +1,61 @@
+load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project")
+load("@com_googlesource_gerrit_bazlets//js:defs.bzl", "gerrit_js_bundle")
+load("//tools/bzl:js.bzl", "web_test_runner")
+
+package_group(
+ name = "visibility",
+ packages = ["//plugins/reparent/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+ts_config(
+ name = "tsconfig",
+ src = "tsconfig.json",
+ deps = [
+ "//plugins:tsconfig-plugins-base",
+ ],
+)
+
+ts_project(
+ name = "reparent-ts",
+ srcs = glob(
+ ["**/*.ts"],
+ exclude = ["**/*test*"],
+ ),
+ incremental = True,
+ out_dir = "_bazel_ts_out",
+ tsconfig = ":tsconfig",
+ deps = [
+ "//plugins:node_modules",
+ ],
+)
+
+ts_project(
+ name = "reparent-ts-tests",
+ srcs = glob(["**/*.ts"]),
+ incremental = True,
+ out_dir = "_bazel_ts_out_tests",
+ tsconfig = ":tsconfig",
+ deps = [
+ "//plugins:node_modules",
+ "//polygerrit-ui:node_modules",
+ ],
+)
+
+gerrit_js_bundle(
+ name = "reparent-project",
+ srcs = [":reparent-ts"],
+ entry_point = "_bazel_ts_out/plugin.js",
+)
+
+web_test_runner(
+ name = "web_test_runner",
+ srcs = ["web_test_runner.sh"],
+ data = [
+ ":reparent-ts-tests",
+ ":tsconfig",
+ "//plugins:node_modules",
+ "//polygerrit-ui:node_modules",
+ ],
+)
diff --git a/web/gr-reparent-repo.ts b/web/gr-reparent-repo.ts
new file mode 100644
index 0000000..c33abaa
--- /dev/null
+++ b/web/gr-reparent-repo.ts
@@ -0,0 +1,176 @@
+/**
+ * @license
+ * Copyright (C) 2026 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 {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {
+ ActionInfo,
+ ConfigInfo,
+ RepoName,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {css, CSSResult, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-reparent-repo': GrReparentRepo;
+ }
+}
+
+@customElement('gr-reparent-repo')
+export class GrReparentRepo extends LitElement {
+ @query('#reparentRepoModal')
+ reparentRepoModal?: HTMLDialogElement;
+
+ @query('#newParentInput')
+ newParentInput?: HTMLInputElement;
+
+ @query('#commitMessageInput')
+ commitMessageInput?: HTMLTextAreaElement;
+
+ /** Guaranteed to be provided by the 'repo-command' endpoint. */
+ @property({type: Object})
+ plugin!: PluginApi;
+
+ /** Guaranteed to be provided by the 'repo-command' endpoint. */
+ @property({type: Object})
+ config!: ConfigInfo;
+
+ /** Guaranteed to be provided by the 'repo-command' endpoint. */
+ @property({type: String})
+ repoName!: RepoName;
+
+ @state()
+ private error?: string;
+
+ static override get styles() {
+ return [
+ window.Gerrit?.styles.font as CSSResult,
+ window.Gerrit?.styles.modal as CSSResult,
+ css`
+ :host {
+ display: block;
+ margin-bottom: var(--spacing-xxl);
+ }
+ h2 {
+ margin-top: var(--spacing-xxl);
+ margin-bottom: var(--spacing-s);
+ }
+ section {
+ margin-bottom: var(--spacing-m);
+ }
+ .error {
+ color: red;
+ }
+ `,
+ ];
+ }
+
+ get action(): ActionInfo | undefined {
+ return this.config?.actions?.[this.actionId];
+ }
+
+ get actionId(): string {
+ return `${this.plugin.getPluginName()}~parent`;
+ }
+
+ private renderError() {
+ if (!this.error) return;
+ return html`<div class="error">${this.error}</div>`;
+ }
+
+ override render() {
+ if (!this.action) return;
+ return html`
+ <h2 class="heading-2">${this.action.label}</h2>
+ <gr-button
+ title="${this.action.title}"
+ ?disabled="${!this.action.enabled}"
+ @click="${() => {
+ this.error = undefined;
+ this.reparentRepoModal?.showModal();
+ }}"
+ >
+ ${this.action.label}
+ </gr-button>
+ ${this.renderError()}
+ <dialog id="reparentRepoModal">
+ <gr-dialog
+ id="reparentRepoDialog"
+ confirm-label="Reparent"
+ @confirm="${this.reparentRepo}"
+ @cancel="${() => this.reparentRepoModal?.close()}"
+ >
+ <div class="header" slot="header">
+ Reparent project "${this.repoName}"
+ </div>
+ <div class="main" slot="main">
+ <section>
+ <label for="newParentInput">New parent project</label><br />
+ <input type="text" id="newParentInput" size="40" />
+ </section>
+ <section>
+ <label for="commitMessageInput">Commit message</label><br />
+ <textarea id="commitMessageInput" rows="3" cols="40"></textarea>
+ </section>
+ </div>
+ </gr-dialog>
+ </dialog>
+ `;
+ }
+
+ private reparentRepo() {
+ if (!this.action) {
+ this.error = 'reparent action undefined';
+ this.reparentRepoModal?.close();
+ return;
+ }
+ if (!this.action.method) {
+ this.error = 'reparent action does not have a HTTP method set';
+ this.reparentRepoModal?.close();
+ return;
+ }
+ this.error = undefined;
+
+ const endpoint = `/projects/${encodeURIComponent(this.repoName)}/${
+ this.actionId
+ }`;
+ const json = {
+ parent: this.newParentInput?.value.trim() ?? '',
+ commit_message: this.commitMessageInput?.value.trim() || undefined,
+ };
+ return this.plugin
+ .restApi()
+ .fetch(this.action.method, endpoint, json)
+ .then(res => this.handleResponse(res))
+ .catch((e: unknown) => {
+ this.handleError(e);
+ });
+ }
+
+ private handleError(e: unknown) {
+ this.error = e === undefined ? 'Error reparenting project' : `${e}`;
+ this.reparentRepoModal?.close();
+ }
+
+ async handleResponse(response: Response | undefined) {
+ if (response?.ok) {
+ this.reparentRepoModal?.close();
+ window.location.reload();
+ } else {
+ this.handleError(undefined);
+ }
+ }
+}
diff --git a/web/gr-reparent-repo_test.ts b/web/gr-reparent-repo_test.ts
new file mode 100644
index 0000000..0940356
--- /dev/null
+++ b/web/gr-reparent-repo_test.ts
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright (C) 2026 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 './gr-reparent-repo';
+import {query, queryAndAssert} from './test/test-util';
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {GrReparentRepo} from './gr-reparent-repo';
+import {
+ ConfigInfo,
+ HttpMethod,
+ RepoName,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {fixture, html, assert} from '@open-wc/testing';
+import sinon from 'sinon';
+
+suite('gr-reparent-repo tests', () => {
+ let element: GrReparentRepo;
+ let fetchStub: sinon.SinonStub;
+
+ setup(async () => {
+ fetchStub = sinon.stub();
+ fetchStub.returns(Promise.resolve({ok: false}));
+
+ element = await fixture(html`<gr-reparent-repo></gr-reparent-repo>`);
+ element.repoName = 'test-repo/name' as RepoName;
+ element.plugin = {
+ getPluginName: () => 'reparent',
+ restApi: () => {
+ return {
+ fetch: fetchStub,
+ };
+ },
+ } as unknown as PluginApi;
+ });
+
+ test('hides action when server omits it', async () => {
+ element.config = {actions: {}} as unknown as ConfigInfo;
+ await element.updateComplete;
+
+ assert.isUndefined(query(element, 'gr-button'));
+ });
+
+ test('confirm and send', async () => {
+ element.config = {
+ actions: {
+ 'reparent~parent': {
+ label: 'Reparent...',
+ title: 'Reparent project test-repo/name',
+ enabled: true,
+ method: HttpMethod.PUT,
+ },
+ },
+ } as unknown as ConfigInfo;
+ await element.updateComplete;
+
+ queryAndAssert<HTMLInputElement>(element, '#newParentInput').value =
+ ' new-parent ';
+ queryAndAssert<HTMLTextAreaElement>(element, '#commitMessageInput').value =
+ ' move test repo ';
+ const dialog = queryAndAssert<HTMLElement>(element, '#reparentRepoDialog');
+ dialog.dispatchEvent(new CustomEvent('confirm'));
+
+ assert.isTrue(fetchStub.calledOnce);
+ const method = fetchStub.firstCall.args[0] as HttpMethod;
+ const endpoint = fetchStub.firstCall.args[1] as string;
+ const json = fetchStub.firstCall.args[2];
+ assert.equal(method, HttpMethod.PUT);
+ assert.equal(endpoint, '/projects/test-repo%2Fname/reparent~parent');
+ assert.deepEqual(json, {
+ parent: 'new-parent',
+ commit_message: 'move test repo',
+ });
+ });
+});
diff --git a/web/plugin.ts b/web/plugin.ts
new file mode 100644
index 0000000..e19e1c1
--- /dev/null
+++ b/web/plugin.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright (C) 2026 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 './gr-reparent-repo';
+
+window.Gerrit?.install(plugin => {
+ plugin.registerCustomComponent('repo-command', 'gr-reparent-repo');
+});
diff --git a/web/test/test-setup.ts b/web/test/test-setup.ts
new file mode 100644
index 0000000..2560213
--- /dev/null
+++ b/web/test/test-setup.ts
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright (C) 2026 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 {css} from 'lit';
+
+window.Gerrit = {
+ install: () => {},
+ styles: {
+ font: css``,
+ form: css``,
+ material: css``,
+ menuPage: css``,
+ spinner: css``,
+ subPage: css``,
+ table: css``,
+ modal: css``,
+ },
+};
diff --git a/web/test/test-util.ts b/web/test/test-util.ts
new file mode 100644
index 0000000..5213982
--- /dev/null
+++ b/web/test/test-util.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright (C) 2026 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 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;
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..65940e1
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig-plugins-base.json",
+ "compilerOptions": {
+ "rootDir": "."
+ },
+ "include": [
+ "**/*.ts"
+ ]
+}
diff --git a/web/web_test_runner.sh b/web/web_test_runner.sh
new file mode 100755
index 0000000..bd04587
--- /dev/null
+++ b/web/web_test_runner.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+set -euo pipefail
+./"$1" --config "$2" \
+ --dir 'plugins/reparent/web/_bazel_ts_out_tests' \
+ --test-files 'plugins/reparent/web/_bazel_ts_out_tests/*_test.js' \
+ --ts-config='plugins/reparent/web/tsconfig.json'