Support showing owner avatars.

When the current user does not own a file, we show a stack of at most 3
avatars of reviewers that are owners for this user. If the current user
does own the file, the shield icon is shown instead.

If no reviewer (or current user) is the owner, a help question is shown
with a tooltip. (See second screenshot).

To fetch this information, `owned_paths` is passed a new parameter
`check_reviewers` which was introduced in Change 348257.

Google-Bug-Id: b/200008775
Screenshot: https://imgur.com/a/uLzzmpP
Screenshot2: https://imgur.com/a/f7zmZ7j
Change-Id: Ifb841a7316ada294c26e9c76221b4581ccb20d94
diff --git a/web/code-owners-api.ts b/web/code-owners-api.ts
index 0039563..8f5ace1 100644
--- a/web/code-owners-api.ts
+++ b/web/code-owners-api.ts
@@ -138,6 +138,7 @@
 export interface OwnedPathInfo {
   path: string;
   owned?: boolean;
+  owners?: Array<AccountInfo>;
 }
 
 export interface OwnedChangedFileInfo {
@@ -192,14 +193,15 @@
   }
 
   /**
-   * Returns a promise fetching which files are owned by a given user.
+   * Returns a promise fetching which files are owned by a given user as well
+   * as which reviewers own which files.
    */
   listOwnedPaths(changeId: NumericChangeId, account: AccountInfo) {
     if (!account.email && !account._account_id)
       return Promise.resolve(undefined);
     const user = account.email ?? account._account_id;
     return this.get(
-      `/changes/${changeId}/revisions/current/owned_paths?user=${user}&limit=10000`
+      `/changes/${changeId}/revisions/current/owned_paths?user=${user}&limit=10000&check_reviewers`
     ) as Promise<OwnedPathsInfo>;
   }
 
diff --git a/web/code-owners-model.ts b/web/code-owners-model.ts
index 324c3e2..38d0f78 100644
--- a/web/code-owners-model.ts
+++ b/web/code-owners-model.ts
@@ -16,7 +16,10 @@
  */
 
 import {BehaviorSubject, Observable} from 'rxjs';
-import {type ChangeInfo} from '@gerritcodereview/typescript-api/rest-api';
+import {
+  AccountInfo,
+  type ChangeInfo,
+} from '@gerritcodereview/typescript-api/rest-api';
 import {
   ChangeType,
   CodeOwnerBranchConfigInfo,
@@ -101,6 +104,8 @@
 export interface OwnedPathsInfoOpt {
   oldPaths: Set<string>;
   newPaths: Set<string>;
+  oldPathOwners: Map<string, Array<AccountInfo>>;
+  newPathOwners: Map<string, Array<AccountInfo>>;
 }
 
 export interface CodeOwnersState {
@@ -296,12 +301,30 @@
     const ownedPaths = {
       oldPaths: new Set<string>(),
       newPaths: new Set<string>(),
+      oldPathOwners: new Map<string, Array<AccountInfo>>(),
+      newPathOwners: new Map<string, Array<AccountInfo>>(),
     };
     for (const changed_file of ownedPathsInfo?.owned_changed_files ?? []) {
       if (changed_file.old_path?.owned)
         ownedPaths.oldPaths.add(changed_file.old_path.path);
       if (changed_file.new_path?.owned)
         ownedPaths.newPaths.add(changed_file.new_path.path);
+      if (changed_file.old_path?.owners) {
+        ownedPaths.oldPathOwners.set(
+          changed_file.old_path.path,
+          (
+            ownedPaths.oldPathOwners.get(changed_file.old_path.path) ?? []
+          ).concat(changed_file.old_path.owners)
+        );
+      }
+      if (changed_file.new_path?.owners) {
+        ownedPaths.newPathOwners.set(
+          changed_file.new_path.path,
+          (
+            ownedPaths.newPathOwners.get(changed_file.new_path.path) ?? []
+          ).concat(changed_file.new_path.owners)
+        );
+      }
     }
     const nextState = {
       ...current,
diff --git a/web/owner-status-column.ts b/web/owner-status-column.ts
index 05d6b3e..19f02cb 100644
--- a/web/owner-status-column.ts
+++ b/web/owner-status-column.ts
@@ -16,6 +16,7 @@
  */
 
 import {
+  AccountInfo,
   BasePatchSetNum,
   RevisionPatchSetNum,
 } from '@gerritcodereview/typescript-api/rest-api';
@@ -81,13 +82,17 @@
 
 export function hasPath(ownedPaths: Set<string>, path: string | undefined) {
   if (!path) return false;
-  if (path.charAt(0) === '/') {
-    if (ownedPaths.has(path)) return true;
-  } else {
-    // NOTE: The backend returns absolute paths.
-    if (ownedPaths.has('/' + path)) return true;
-  }
-  return false;
+  if (path.charAt(0) !== '/') path = '/' + path;
+  return ownedPaths.has(path);
+}
+
+export function getOwners(
+  owners: Map<string, Array<AccountInfo>>,
+  path: string | undefined
+): Array<AccountInfo> {
+  if (!path) return [];
+  if (path.charAt(0) !== '/') path = '/' + path;
+  return owners.get(path) ?? [];
 }
 
 const base = CodeOwnersModelMixin(LitElement);
@@ -140,7 +145,7 @@
         :host() {
           display: block;
           padding-right: var(--spacing-m);
-          width: 3em;
+          width: 5em;
         }
       `,
     ];
@@ -179,7 +184,7 @@
         :host {
           display: flex;
           padding-right: var(--spacing-m);
-          width: 3em;
+          width: 5em;
           text-align: center;
         }
         gr-icon {
@@ -194,6 +199,21 @@
         :host([owner-status='missing']) gr-icon.status {
           color: var(--negative-red-text-color);
         }
+        gr-avatar-stack {
+          padding: var(--spacing-xs) 0px;
+          display: flex;
+          --avatar-size: 20px;
+        }
+        .ellipsis {
+          /* These are required to get the ... to line up with the bottom of
+             the avatar icons. */
+          margin-bottom: -2px;
+          display: flex;
+          align-items: flex-end;
+        }
+        .error {
+          color: var(--negative-red-text-color);
+        }
       `,
     ];
   }
@@ -204,7 +224,12 @@
   }
 
   override render() {
-    if (this.computeHidden() || this.status === undefined) return nothing;
+    if (
+      this.computeHidden() ||
+      this.status === undefined ||
+      this.path === '/COMMIT_MSG'
+    )
+      return nothing;
     return html`${this.renderStatus()}${this.renderOwnership()}`;
   }
 
@@ -225,17 +250,41 @@
   }
 
   private renderOwnership() {
-    if (!this.isOwned()) return nothing;
-    return html`
-      <gr-tooltip-content
-        title="You are in OWNERS for this file"
-        aria-label="owned"
-        aria-description="You are an owner of this file"
-        has-tooltip
-      >
-        <gr-icon filled icon="policy" aria-hidden="true"></gr-icon>
-      </gr-tooltip-content>
-    `;
+    if (this.isOwned()) {
+      return html`
+        <gr-tooltip-content
+          title="You are in OWNERS for this file"
+          aria-label="owned"
+          aria-description="You are an owner of this file"
+          has-tooltip
+        >
+          <gr-icon filled icon="policy" aria-hidden="true"></gr-icon>
+        </gr-tooltip-content>
+      `;
+    } else if (this.ownedPaths) {
+      const oldOwners = getOwners(this.ownedPaths.oldPathOwners, this.oldPath);
+      const newOwners = getOwners(this.ownedPaths.newPathOwners, this.path);
+      const allOwners = oldOwners.concat(newOwners);
+
+      return html` <gr-avatar-stack
+          .accounts=${allOwners.slice(0, 3)}
+          .forceFetch=${true}
+        >
+          <gr-tooltip-content
+            slot="fallback"
+            title="No reviewer owns this file"
+            aria-label="missing owner"
+            aria-description="No reviewer owns this file"
+            has-tooltip
+          >
+            <gr-icon icon="help" class="error"></gr-icon>
+          </gr-tooltip-content>
+        </gr-avatar-stack>
+        ${allOwners.length > 3
+          ? html`<div class="ellipsis">…</div>`
+          : nothing}`;
+    }
+    return nothing;
   }
 
   private isOwned() {