Introduce the skeleton of UI for file owners status display

This is the initial change that introduces the base building blocks for
UI that will be responsible for showing (to the change owner) which file
needs to be reviewed from whom. At present it:
* hooks into the change details screen's Files section (for table header
  and each row)
* determines if user is logged (logs to console value of `hidden`,
  `userRole`, `path` and `oldPath` properties)
* introduces base building blocks for UI components (heavily inspired by
  the `code-owners` plugin UI) namely: common UI component that is
  shared between column header and content controls, thin owners model
  that is loaded reactively through the loader using a dedicated service
  (that finally calls `RestPluginApi`)
* owners service and `shouldHide` function are covered with karma basic
  unit tests
* integrates the UI part with an `owners` plugin build and resources
  (offered as `static` resource by introduction of `HttpModule`)

Note that:
* UI tests can be called from gerrit in-tree build with

    bazel test //plugins/owners/web:karma_test

* linter can be called from gerrit in-tree build with:

    node_modules/eslint/bin/eslint.js --ext .html,.ts --fix plugins/owners/web

Bug: Issue 373151160
Change-Id: I529d1345528da0322bee2c360241935a8b4379e6
diff --git a/owners/BUILD b/owners/BUILD
index f08ec84..c1dac46 100644
--- a/owners/BUILD
+++ b/owners/BUILD
@@ -38,7 +38,9 @@
         "Implementation-URL: https://gerrit.googlesource.com/plugins/owners",
         "Gerrit-PluginName: owners",
         "Gerrit-Module: com.googlesource.gerrit.owners.OwnersModule",
+        "Gerrit-HttpModule: com.googlesource.gerrit.owners.HttpModule",
     ],
+    resource_jars = ["//plugins/owners/web:gr-owners"],
     resources = glob(["src/main/resources/**/*"]),
     deps = [
         ":gerrit-owners-predicates",
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/HttpModule.java b/owners/src/main/java/com/googlesource/gerrit/owners/HttpModule.java
new file mode 100644
index 0000000..10759c0
--- /dev/null
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/HttpModule.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2024 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.
+package com.googlesource.gerrit.owners;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.webui.JavaScriptPlugin;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.inject.servlet.ServletModule;
+
+public class HttpModule extends ServletModule {
+  @Override
+  protected void configureServlets() {
+    DynamicSet.bind(binder(), WebUiPlugin.class).toInstance(new JavaScriptPlugin("gr-owners.js"));
+  }
+}
diff --git a/owners/web/.eslintrc.js b/owners/web/.eslintrc.js
new file mode 100644
index 0000000..776d84e
--- /dev/null
+++ b/owners/web/.eslintrc.js
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright (C) 2024 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 = 'owners/web';
+module.exports = {
+  extends: '../../.eslintrc.js',
+};
\ No newline at end of file
diff --git a/owners/web/BUILD b/owners/web/BUILD
new file mode 100644
index 0000000..620f1b2
--- /dev/null
+++ b/owners/web/BUILD
@@ -0,0 +1,62 @@
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
+load("//tools/bzl:js.bzl", "gerrit_js_bundle", "karma_test")
+load("//tools/js:eslint.bzl", "plugin_eslint")
+
+package_group(
+    name = "visibility",
+    packages = ["//plugins/owners/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+ts_config(
+    name = "tsconfig",
+    src = "tsconfig.json",
+    deps = [
+        "//plugins:tsconfig-plugins-base.json",
+    ],
+)
+
+ts_project(
+    name = "owners-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",
+        "@plugins_npm//lit",
+        "@plugins_npm//rxjs",
+    ],
+)
+
+ts_project(
+    name = "owners-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 = "gr-owners",
+    srcs = [":owners-ts"],
+    entry_point = "_bazel_ts_out/plugin.js",
+)
+
+karma_test(
+    name = "karma_test",
+    srcs = ["karma_test.sh"],
+    data = [":owners-ts-tests"],
+)
+
+plugin_eslint()
diff --git a/owners/web/gr-owners.ts b/owners/web/gr-owners.ts
new file mode 100644
index 0000000..b29fd27
--- /dev/null
+++ b/owners/web/gr-owners.ts
@@ -0,0 +1,195 @@
+/**
+ * @license
+ * Copyright (C) 2024 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 {Subscription} from 'rxjs';
+import {LitElement, nothing, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {
+  ChangeInfo,
+  ChangeStatus,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {OwnersService} from './owners-service';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {ModelLoader, OwnersModel, PatchRange, UserRole} from './owners-model';
+
+// Lit mixin definition as described in https://lit.dev/docs/composition/mixins/
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type Constructor<T> = new (...args: any[]) => T;
+
+interface CommonInterface {
+  change?: ChangeInfo;
+  patchRange?: PatchRange;
+  restApi?: RestPluginApi;
+  userRole?: UserRole;
+
+  onModelUpdate(): void;
+}
+
+const CommonMixin = <T extends Constructor<LitElement>>(superClass: T) => {
+  class Mixin extends superClass {
+    @property({type: Object})
+    change?: ChangeInfo;
+
+    @property({type: Object})
+    patchRange?: PatchRange;
+
+    @property({type: Object})
+    restApi?: RestPluginApi;
+
+    @state()
+    userRole?: UserRole;
+
+    private _model?: OwnersModel;
+
+    modelLoader?: ModelLoader;
+
+    private subscriptions: Array<Subscription> = [];
+
+    get model() {
+      return this._model;
+    }
+
+    set model(model: OwnersModel | undefined) {
+      if (this._model === model) return;
+      for (const s of this.subscriptions) {
+        s.unsubscribe();
+      }
+      this.subscriptions = [];
+      this._model = model;
+      if (!model) return;
+
+      this.subscriptions.push(
+        model.state$.subscribe(s => {
+          this.userRole = s.userRole;
+        })
+      );
+
+      this.onModelUpdate();
+    }
+
+    protected override willUpdate(changedProperties: PropertyValues): void {
+      super.willUpdate(changedProperties);
+
+      if (changedProperties.has('change') || changedProperties.has('restApi')) {
+        if (!this.restApi || !this.change) {
+          this.model = undefined;
+          this.modelLoader = undefined;
+          return;
+        }
+        const service = OwnersService.getOwnersService(
+          this.restApi,
+          this.change
+        );
+        const model = OwnersModel.getModel(this.change);
+        this.modelLoader = new ModelLoader(service, model);
+        this.model = model;
+      }
+
+      this.hidden = shouldHide(this.change, this.patchRange, this.userRole);
+    }
+
+    protected onModelUpdate() {
+      this.modelLoader?.loadUserRole();
+    }
+
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    constructor(...args: any[]) {
+      super(...args);
+    }
+  }
+
+  return Mixin as unknown as T & Constructor<CommonInterface>;
+};
+
+const common = CommonMixin(LitElement);
+
+export const FILE_OWNERS_COLUMN_HEADER = 'file-owners-column-header';
+@customElement(FILE_OWNERS_COLUMN_HEADER)
+export class FileOwnersColumnHeader extends common {
+  static override get styles() {
+    return [];
+  }
+
+  override render() {
+    console.log(
+      `hidden: ${shouldHide(
+        this.change,
+        this.patchRange,
+        this.userRole
+      )}, userRole: ${this.userRole}`
+    );
+    return nothing;
+  }
+}
+
+export const FILE_OWNERS_COLUMN_CONTENT = 'file-owners-column-content';
+@customElement(FILE_OWNERS_COLUMN_CONTENT)
+export class FileOwnersColumnContent extends common {
+  @property({type: String})
+  path?: string;
+
+  @property({type: String})
+  oldPath?: string;
+
+  static override get styles() {
+    return [];
+  }
+
+  override render() {
+    console.log(
+      `hidden: ${this.hidden}, userRole: ${this.userRole}, path: ${this.path}, oldPath: ${this.oldPath}`
+    );
+    return nothing;
+  }
+}
+
+export function shouldHide(
+  change?: ChangeInfo,
+  patchRange?: PatchRange,
+  userRole?: UserRole
+): boolean {
+  // don't show owners when no change or change is merged
+  if (change === undefined || patchRange === undefined) {
+    return true;
+  }
+  if (
+    change.status === ChangeStatus.ABANDONED ||
+    change.status === ChangeStatus.MERGED
+  ) {
+    return true;
+  }
+
+  // Note: in some special cases, patchNum is undefined on latest patchset
+  // like after publishing the edit, still show for them
+  // TODO: this should be fixed in Gerrit
+  if (patchRange?.patchNum === undefined) return false;
+
+  // only show if its latest patchset
+  const latestPatchset = change.revisions![change.current_revision!];
+  if (`${patchRange.patchNum}` !== `${latestPatchset._number}`) {
+    return true;
+  }
+
+  // show owners when they apply to the change and for logged in user
+  if (
+    change.submit_requirements &&
+    change.submit_requirements.find(r => r.name === 'Owner-Approval')
+  ) {
+    return !userRole || userRole === UserRole.ANONYMOUS;
+  }
+  return true;
+}
diff --git a/owners/web/gr-owners_test.ts b/owners/web/gr-owners_test.ts
new file mode 100644
index 0000000..bf29042
--- /dev/null
+++ b/owners/web/gr-owners_test.ts
@@ -0,0 +1,147 @@
+/**
+ * @license
+ * Copyright (C) 2024 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 {assert} from '@open-wc/testing';
+
+import {shouldHide} from './gr-owners';
+import {PatchRange, UserRole} from './owners-model';
+import {
+  ChangeInfo,
+  ChangeStatus,
+  SubmitRequirementResultInfo,
+} from '@gerritcodereview/typescript-api/rest-api';
+
+suite('owners status tests', () => {
+  suite('shouldHide tests', () => {
+    const loggedIn = getRandom(UserRole.CHANGE_OWNER, UserRole.OTHER);
+
+    test('shouldHide - should be `true` when change is not defined', () => {
+      const undefinedChange = undefined;
+      const definedPatchRange = {} as unknown as PatchRange;
+      assert.equal(
+        shouldHide(undefinedChange, definedPatchRange, loggedIn),
+        true
+      );
+    });
+
+    test('shouldHide - should be `true` when patch range is not defined', () => {
+      const definedChange = {} as unknown as ChangeInfo;
+      const undefinedPatchRange = undefined;
+      assert.equal(
+        shouldHide(definedChange, undefinedPatchRange, loggedIn),
+        true
+      );
+    });
+
+    test('shouldHide - should be `true` when change is abandoned', () => {
+      const abandonedChange = {
+        status: ChangeStatus.ABANDONED,
+      } as unknown as ChangeInfo;
+      const definedPatchRange = {} as unknown as PatchRange;
+      assert.equal(
+        shouldHide(abandonedChange, definedPatchRange, loggedIn),
+        true
+      );
+    });
+
+    test('shouldHide - should be `true` when change is merged', () => {
+      const mergedChange = {
+        status: ChangeStatus.MERGED,
+      } as unknown as ChangeInfo;
+      const definedPatchRange = {} as unknown as PatchRange;
+      assert.equal(shouldHide(mergedChange, definedPatchRange, loggedIn), true);
+    });
+
+    test('shouldHide - should be `true` if not on the latest PS', () => {
+      const changeWithPs2 = {
+        status: ChangeStatus.NEW,
+        revisions: {
+          current_rev: {_number: 2},
+        },
+        current_revision: 'current_rev',
+      } as unknown as ChangeInfo;
+      const patchRangeOnPs1 = {patchNum: 1} as unknown as PatchRange;
+      assert.equal(shouldHide(changeWithPs2, patchRangeOnPs1, loggedIn), true);
+    });
+
+    const change = {
+      status: ChangeStatus.NEW,
+      revisions: {
+        current_rev: {_number: 1},
+      },
+      current_revision: 'current_rev',
+    } as unknown as ChangeInfo;
+    const patchRange = {patchNum: 1} as unknown as PatchRange;
+
+    test('shouldHide - should be `true` when change has no submit requirements', () => {
+      assert.equal(shouldHide(change, patchRange, loggedIn), true);
+    });
+
+    test('shouldHide - should be `true` when change has no `Owner-Approval` submit requirements', () => {
+      const changeWithDifferentSubmitReqs = {
+        ...change,
+        submit_requirements: [
+          {name: 'other'},
+        ] as unknown as SubmitRequirementResultInfo[],
+      };
+      assert.equal(
+        shouldHide(changeWithDifferentSubmitReqs, patchRange, loggedIn),
+        true
+      );
+    });
+
+    test('shouldHide - should be `true` when user is not logged in', () => {
+      const changeWithSubmitRequirements = {
+        ...change,
+        submit_requirements: [
+          {name: 'Owner-Approval'},
+        ] as unknown as SubmitRequirementResultInfo[],
+      };
+      const anonymous = UserRole.ANONYMOUS;
+      assert.equal(
+        shouldHide(changeWithSubmitRequirements, patchRange, anonymous),
+        true
+      );
+    });
+
+    test('shouldHide - should be `false` when change has submit requirements and user is logged in', () => {
+      const changeWithSubmitRequirements = {
+        ...change,
+        submit_requirements: [
+          {name: 'Owner-Approval'},
+        ] as unknown as SubmitRequirementResultInfo[],
+      };
+      assert.equal(
+        shouldHide(changeWithSubmitRequirements, patchRange, loggedIn),
+        false
+      );
+    });
+
+    test('shouldHide - should be `false` when in edit mode', () => {
+      const patchRangeWithoutPatchNum = {} as unknown as PatchRange;
+      assert.equal(
+        shouldHide(change, patchRangeWithoutPatchNum, loggedIn),
+        false
+      );
+    });
+  });
+});
+
+function getRandom<T>(...values: T[]): T {
+  const idx = Math.floor(Math.random() * values.length);
+  return values[idx];
+}
diff --git a/owners/web/karma_test.sh b/owners/web/karma_test.sh
new file mode 100755
index 0000000..7cecdae
--- /dev/null
+++ b/owners/web/karma_test.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+set -euo pipefail
+./$1 start $2 --single-run \
+  --root 'plugins/owners/web/_bazel_ts_out_tests/' \
+  --test-files '*_test.js' \
+  --browsers ${3:-ChromeHeadless}
diff --git a/owners/web/owners-model.ts b/owners/web/owners-model.ts
new file mode 100644
index 0000000..952fa3f
--- /dev/null
+++ b/owners/web/owners-model.ts
@@ -0,0 +1,109 @@
+/**
+ * @license
+ * Copyright (C) 2024 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 {BehaviorSubject, Observable} from 'rxjs';
+import {
+  BasePatchSetNum,
+  ChangeInfo,
+  RevisionPatchSetNum,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {OwnersService} from './owners-service';
+
+export interface PatchRange {
+  patchNum: RevisionPatchSetNum;
+  basePatchNum: BasePatchSetNum;
+}
+
+export enum UserRole {
+  ANONYMOUS = 'ANONYMOUS',
+  CHANGE_OWNER = 'CHANGE_OWNER',
+  OTHER = 'OTHER',
+}
+
+export interface OwnersState {
+  userRole?: UserRole;
+}
+
+let ownersModel: OwnersModel | undefined;
+
+export class OwnersModel extends EventTarget {
+  private subject$: BehaviorSubject<OwnersState> = new BehaviorSubject(
+    {} as OwnersState
+  );
+
+  public state$: Observable<OwnersState> = this.subject$.asObservable();
+
+  constructor(readonly change: ChangeInfo) {
+    super();
+  }
+
+  get state() {
+    return this.subject$.getValue();
+  }
+
+  private setState(state: OwnersState) {
+    this.subject$.next(Object.freeze(state));
+  }
+
+  setUserRole(userRole: UserRole) {
+    const current = this.subject$.getValue();
+    if (current.userRole === userRole) return;
+    this.setState({...current, userRole});
+  }
+
+  static getModel(change: ChangeInfo) {
+    if (!ownersModel || ownersModel.change !== change) {
+      ownersModel = new OwnersModel(change);
+    }
+    return ownersModel;
+  }
+}
+
+export class ModelLoader {
+  constructor(
+    private readonly service: OwnersService,
+    private readonly model: OwnersModel
+  ) {}
+
+  async loadUserRole() {
+    await this._loadProperty(
+      'userRole',
+      () => this.service.getLoggedInUserRole(),
+      value => this.model.setUserRole(value)
+    );
+  }
+
+  private async _loadProperty<K extends keyof OwnersState, T>(
+    propertyName: K,
+    propertyLoader: () => Promise<T>,
+    propertySetter: (value: T) => void
+  ) {
+    if (this.model.state[propertyName] !== undefined) return;
+    let newValue: T;
+    try {
+      newValue = await propertyLoader();
+    } catch (e) {
+      console.error(e);
+      return;
+    }
+    // It is possible, that several requests is made in parallel.
+    // Store only the first result and discard all other results.
+    // (also, due to the CodeOwnersCacheApi all result must be identical)
+    if (this.model.state[propertyName] !== undefined) return;
+    propertySetter(newValue);
+  }
+}
diff --git a/owners/web/owners-service.ts b/owners/web/owners-service.ts
new file mode 100644
index 0000000..6abf6e9
--- /dev/null
+++ b/owners/web/owners-service.ts
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright (C) 2024 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 {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {
+  AccountDetailInfo,
+  ChangeInfo,
+} from '@gerritcodereview/typescript-api/rest-api';
+import {UserRole} from './owners-model';
+
+class OwnersApi {
+  constructor(readonly restApi: RestPluginApi) {}
+
+  async getAccount(): Promise<AccountDetailInfo | undefined> {
+    const loggedIn = await this.restApi.getLoggedIn();
+    if (!loggedIn) return undefined;
+    return await this.restApi.getAccount();
+  }
+}
+
+let service: OwnersService | undefined;
+
+export class OwnersService {
+  private api: OwnersApi;
+
+  constructor(readonly restApi: RestPluginApi, readonly change: ChangeInfo) {
+    this.api = new OwnersApi(restApi);
+  }
+
+  async getLoggedInUserRole(): Promise<UserRole> {
+    const account = await this.api.getAccount();
+    if (!account) {
+      return UserRole.ANONYMOUS;
+    }
+    if (this.change.owner._account_id === account._account_id) {
+      return UserRole.CHANGE_OWNER;
+    }
+    return UserRole.OTHER;
+  }
+
+  static getOwnersService(restApi: RestPluginApi, change: ChangeInfo) {
+    if (!service || service.change !== change) {
+      service = new OwnersService(restApi, change);
+    }
+    return service;
+  }
+}
diff --git a/owners/web/owners-service_test.ts b/owners/web/owners-service_test.ts
new file mode 100644
index 0000000..f593304
--- /dev/null
+++ b/owners/web/owners-service_test.ts
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright (C) 2024 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 {OwnersService} from './owners-service';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {ChangeInfo} from '@gerritcodereview/typescript-api/rest-api';
+import {assert} from '@open-wc/testing';
+import {UserRole} from './owners-model';
+
+suite('owners service tests', () => {
+  const fakeRestApi = {} as unknown as RestPluginApi;
+  const fakeChange = {} as unknown as ChangeInfo;
+
+  suite('basic api request tests', () => {
+    test('getOwnersService - same change returns the same instance', () => {
+      assert.equal(
+        OwnersService.getOwnersService(fakeRestApi, fakeChange),
+        OwnersService.getOwnersService(fakeRestApi, fakeChange)
+      );
+    });
+
+    test('getOwnersService - modified change returns new instance', () => {
+      assert.notEqual(
+        OwnersService.getOwnersService(fakeRestApi, {...fakeChange}),
+        OwnersService.getOwnersService(fakeRestApi, {...fakeChange})
+      );
+    });
+  });
+
+  suite('user role tests', () => {
+    test('getLoggedInUserRole - returns ANONYMOUS when user not logged in', async () => {
+      const notLoggedInApi = {
+        getLoggedIn() {
+          return Promise.resolve(false);
+        },
+      } as unknown as RestPluginApi;
+
+      const service = OwnersService.getOwnersService(
+        notLoggedInApi,
+        fakeChange
+      );
+      const userRole = await service.getLoggedInUserRole();
+      assert.equal(userRole, UserRole.ANONYMOUS);
+    });
+
+    test('getLoggedInUserRole - returns OTHER for logged in user that is NOT change owner', async () => {
+      const userLoggedInApi = {
+        getLoggedIn() {
+          return Promise.resolve(true);
+        },
+        getAccount() {
+          return Promise.resolve(account(2));
+        },
+      } as unknown as RestPluginApi;
+      const change = {owner: account(1)} as unknown as ChangeInfo;
+
+      const service = OwnersService.getOwnersService(userLoggedInApi, change);
+      const userRole = await service.getLoggedInUserRole();
+      assert.equal(userRole, UserRole.OTHER);
+    });
+
+    test('getLoggedInUserRole - returns CHANGE_OWNER for logged in user that is a change owner', async () => {
+      const changeOwnerLoggedInApi = {
+        getLoggedIn() {
+          return Promise.resolve(true);
+        },
+        getAccount() {
+          return Promise.resolve(account(1));
+        },
+      } as unknown as RestPluginApi;
+      const change = {owner: account(1)} as unknown as ChangeInfo;
+
+      const service = OwnersService.getOwnersService(
+        changeOwnerLoggedInApi,
+        change
+      );
+      const userRole = await service.getLoggedInUserRole();
+      assert.equal(userRole, UserRole.CHANGE_OWNER);
+    });
+  });
+});
+
+function account(id: number) {
+  return {
+    _account_id: id,
+  };
+}
diff --git a/owners/web/plugin.ts b/owners/web/plugin.ts
new file mode 100644
index 0000000..8ccc0ab
--- /dev/null
+++ b/owners/web/plugin.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright (C) 2024 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 {
+  FILE_OWNERS_COLUMN_CONTENT,
+  FILE_OWNERS_COLUMN_HEADER,
+  FileOwnersColumnContent,
+  FileOwnersColumnHeader,
+} from './gr-owners';
+
+window.Gerrit.install(plugin => {
+  const restApi = plugin.restApi();
+
+  plugin
+    .registerDynamicCustomComponent(
+      'change-view-file-list-header-prepend',
+      FILE_OWNERS_COLUMN_HEADER
+    )
+    .onAttached(view => {
+      (view as unknown as FileOwnersColumnHeader).restApi = restApi;
+    });
+  plugin
+    .registerDynamicCustomComponent(
+      'change-view-file-list-content-prepend',
+      FILE_OWNERS_COLUMN_CONTENT
+    )
+    .onAttached(view => {
+      (view as unknown as FileOwnersColumnContent).restApi = restApi;
+    });
+});
diff --git a/owners/web/tsconfig.json b/owners/web/tsconfig.json
new file mode 100644
index 0000000..c4f9468
--- /dev/null
+++ b/owners/web/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig-plugins-base.json",
+  "compilerOptions": {
+    "outDir": "../../../.ts-out/plugins/owners", /* overridden by bazel */
+  },
+  "include": [
+    "**/*.ts"
+  ]
+}