Display file owner's vote value in the File Owners summary

Display the vote value of a label that is required in order to satisfy
the OWNERS definition as returned from the plugin's API (typically
`Code-Review`). Note that default or different (e.g. `Verified`) labels
are not shown as they are not relevant from the owners perspective.

The `computeApprovalAndInfo` function (that is responsible for getting
the necessary information) is covered in the unit tests.

Note that a dedicated UI component (`gr-owner`) was introduced to
encapsulate the owner's details. It has to be a part of `gr-owners` file
as otherwise it is not properly loaded.

TODO: add copy file owner's email button

Bug: Issue 373151160
Change-Id: I07919c66f4cda95da40a62086a1b832aec7e8a4a
diff --git a/owners/web/gr-owners.ts b/owners/web/gr-owners.ts
index b989eba..4730586 100644
--- a/owners/web/gr-owners.ts
+++ b/owners/web/gr-owners.ts
@@ -20,12 +20,16 @@
 import {customElement, property, state} from 'lit/decorators';
 import {
   AccountInfo,
+  ApprovalInfo,
   ChangeInfo,
   ChangeStatus,
+  LabelInfo,
+  isDetailedLabelInfo,
 } from '@gerritcodereview/typescript-api/rest-api';
 import {
   FilesOwners,
   isOwner,
+  OwnersLabels,
   OWNERS_SUBMIT_REQUIREMENT,
   OwnersService,
 } from './owners-service';
@@ -197,6 +201,58 @@
   }
 }
 
+/**
+ * It has to be part of this file as components defined in dedicated files are not visible
+ */
+@customElement('gr-owner')
+export class GrOwner extends LitElement {
+  @property({type: Object})
+  owner?: AccountInfo;
+
+  @property({type: Object})
+  approval?: ApprovalInfo;
+
+  @property({type: Object})
+  info?: LabelInfo;
+
+  static override get styles() {
+    return [
+      css`
+        .container {
+          display: flex;
+        }
+        gr-vote-chip {
+          margin-left: 5px;
+          --gr-vote-chip-width: 14px;
+          --gr-vote-chip-height: 14px;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.owner) {
+      return nothing;
+    }
+
+    const voteChip = this.approval
+      ? html` <gr-vote-chip
+          slot="vote-chip"
+          .vote=${this.approval}
+          .label=${this.info}
+          circle-shape
+        ></gr-vote-chip>`
+      : nothing;
+
+    return html`
+      <div class="container">
+        <gr-account-label .account=${this.owner}></gr-account-label>
+        ${voteChip}
+      </div>
+    `;
+  }
+}
+
 export const FILE_OWNERS_COLUMN_CONTENT = 'file-owners-column-content';
 @customElement(FILE_OWNERS_COLUMN_CONTENT)
 export class FileOwnersColumnContent extends common {
@@ -318,17 +374,27 @@
         </div>
         <div class="section">
           <div class="sectionContent">
-            ${splicedOwners.map(
-              (owner, idx) => html`
+            ${splicedOwners.map((owner, idx) => {
+              const [approval, info] =
+                computeApprovalAndInfo(
+                  owner,
+                  this.filesOwners?.owners_labels ?? {},
+                  this.change
+                ) ?? [];
+              return html`
                 <div
                   class="row ${showEllipsis || idx + 1 < splicedOwners.length
                     ? 'notLast'
                     : ''}"
                 >
-                  <gr-account-label .account=${owner}></gr-account-label>
+                  <gr-owner
+                    .owner=${owner}
+                    .approval=${approval}
+                    .info=${info}
+                  ></gr-owner>
                 </div>
-              `
-            )}
+              `;
+            })}
             ${showEllipsis
               ? html`
                 <gr-tooltip-content
@@ -430,3 +496,37 @@
         owners: fileOwners,
       }) as unknown as FileOwnership;
 }
+
+export function computeApprovalAndInfo(
+  fileOwner: AccountInfo,
+  labels: OwnersLabels,
+  change?: ChangeInfo
+): [ApprovalInfo, LabelInfo] | undefined {
+  if (!change?.labels) {
+    return;
+  }
+  const ownersLabel = labels[`${fileOwner._account_id}`];
+  if (!ownersLabel) {
+    return;
+  }
+
+  for (const label of Object.keys(ownersLabel)) {
+    const info = change.labels[label];
+    if (!info || !isDetailedLabelInfo(info)) {
+      return;
+    }
+
+    const vote = ownersLabel[label];
+    if ((info.default_value && info.default_value === vote) || vote === 0) {
+      // ignore default value
+      return;
+    }
+
+    const approval = info.all?.filter(
+      x => x._account_id === fileOwner._account_id
+    )[0];
+    return approval ? [approval, info] : undefined;
+  }
+
+  return;
+}
diff --git a/owners/web/gr-owners_test.ts b/owners/web/gr-owners_test.ts
index 0b4a5ec..4574c3e 100644
--- a/owners/web/gr-owners_test.ts
+++ b/owners/web/gr-owners_test.ts
@@ -17,14 +17,21 @@
 
 import {assert} from '@open-wc/testing';
 
-import {getFileOwnership, shouldHide} from './gr-owners';
+import {
+  computeApprovalAndInfo,
+  getFileOwnership,
+  shouldHide,
+} from './gr-owners';
 import {FileOwnership, FileStatus, PatchRange, UserRole} from './owners-model';
 import {
+  AccountInfo,
+  ApprovalInfo,
   ChangeInfo,
   ChangeStatus,
+  DetailedLabelInfo,
   SubmitRequirementResultInfo,
 } from '@gerritcodereview/typescript-api/rest-api';
-import {FilesOwners} from './owners-service';
+import {FilesOwners, OwnersLabels} from './owners-service';
 import {deepEqual} from './utils';
 
 suite('owners status tests', () => {
@@ -250,6 +257,101 @@
       );
     });
   });
+
+  suite('computeApprovalAndInfo tests', () => {
+    const account = 1;
+    const fileOwner = {_account_id: account} as unknown as AccountInfo;
+    const label = 'Code-Review';
+    const crPlus1OwnersVote = {
+      [`${account}`]: {[label]: 1},
+    } as unknown as OwnersLabels;
+    const changeWithLabels = {
+      labels: {
+        [label]: {
+          all: [
+            {
+              value: 1,
+              date: '2024-10-22 17:26:21.000000000',
+              permitted_voting_range: {
+                min: -2,
+                max: 2,
+              },
+              _account_id: account,
+            },
+          ],
+          values: {
+            '-2': 'This shall not be submitted',
+            '-1': 'I would prefer this is not submitted as is',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          description: '',
+          default_value: 0,
+        },
+      },
+    } as unknown as ChangeInfo;
+
+    test('computeApprovalAndInfo - should be `undefined` when change is `undefined', () => {
+      const undefinedChange = undefined;
+      assert.equal(
+        computeApprovalAndInfo(fileOwner, crPlus1OwnersVote, undefinedChange),
+        undefined
+      );
+    });
+
+    test('computeApprovalAndInfo - should be `undefined` when there is no owners vote', () => {
+      const emptyOwnersVote = {};
+      assert.equal(
+        computeApprovalAndInfo(fileOwner, emptyOwnersVote, changeWithLabels),
+        undefined
+      );
+    });
+
+    test('computeApprovalAndInfo - should be `undefined` for default owners vote', () => {
+      const defaultOwnersVote = {[label]: 0} as unknown as OwnersLabels;
+      assert.equal(
+        computeApprovalAndInfo(fileOwner, defaultOwnersVote, changeWithLabels),
+        undefined
+      );
+    });
+
+    test('computeApprovalAndInfo - should be computed for CR+1 owners vote', () => {
+      const expectedApproval = {
+        value: 1,
+        date: '2024-10-22 17:26:21.000000000',
+        permitted_voting_range: {
+          min: -2,
+          max: 2,
+        },
+        _account_id: account,
+      } as unknown as ApprovalInfo;
+      const expectedInfo = {
+        all: [expectedApproval],
+        values: {
+          '-2': 'This shall not be submitted',
+          '-1': 'I would prefer this is not submitted as is',
+          ' 0': 'No score',
+          '+1': 'Looks good to me, but someone else must approve',
+          '+2': 'Looks good to me, approved',
+        },
+        description: '',
+        default_value: 0,
+      } as unknown as DetailedLabelInfo;
+
+      assert.equal(
+        deepEqual(
+          computeApprovalAndInfo(
+            fileOwner,
+            crPlus1OwnersVote,
+            changeWithLabels
+          ),
+          [expectedApproval, expectedInfo]
+        ),
+        true
+      );
+    });
+  });
 });
 
 function getRandom<T>(...values: T[]): T {