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'