Add getCodeReviewVotesFromMessage method

We will use this method to render the votes from the user in the
patchset picker.

Release-Notes: skip
Google-bug-id: b/230605262
Change-Id: I4117d023b40af3d6863aaad236b44abe8ac9b05d
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
index 05492dd..2d76e47 100644
--- a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -8,27 +8,14 @@
 import {css, html, LitElement, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {ChangeInfo, PatchSetNumber} from '../../../api/rest-api';
-import {
-  LabelExtreme,
-  PATCH_SET_PREFIX_PATTERN,
-} from '../../../utils/comment-util';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {LabelExtreme} from '../../../utils/comment-util';
 import {getTriggerVotes} from '../../../utils/label-util';
 import {ChangeMessage} from '../../../types/common';
 import {CheckRun} from '../../../api/checks';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
-
-const VOTE_RESET_TEXT = '0 (vote reset)';
-
-interface Score {
-  label?: string;
-  value?: string;
-}
-
-export const LABEL_TITLE_SCORE_PATTERN =
-  /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.:]?$/;
+import {getScores, Score, VOTE_RESET_TEXT} from '../../../utils/message-util';
 
 @customElement('gr-message-scores')
 export class GrMessageScores extends LitElement {
@@ -116,7 +103,7 @@
   }
 
   override render() {
-    const scores = this._getScores(this.message, this.labelExtremes);
+    const scores = getScores(this.message, this.labelExtremes);
     const triggerVotes = getTriggerVotes(this.change);
     return scores.map(score => this.renderScore(score, triggerVotes));
   }
@@ -186,32 +173,6 @@
     }
     return classes.join(' ');
   }
-
-  _getScores(message?: ChangeMessage, labelExtremes?: LabelExtreme): Score[] {
-    if (!message || !message.message || !labelExtremes) {
-      return [];
-    }
-    const line = message.message.split('\n', 1)[0];
-    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
-    if (!line.match(patchSetPrefix)) {
-      return [];
-    }
-    const scoresRaw = line.split(patchSetPrefix)[1];
-    if (!scoresRaw) {
-      return [];
-    }
-    return scoresRaw
-      .split(' ')
-      .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
-      .filter(
-        ms => ms && ms.length === 4 && hasOwnProperty(labelExtremes, ms[2])
-      )
-      .map(ms => {
-        const label = ms?.[2];
-        const value = ms?.[1] === '-' ? VOTE_RESET_TEXT : ms?.[3];
-        return {label, value};
-      });
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 390ebff..e55631a 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -33,7 +33,6 @@
   LabelExtreme,
   PATCH_SET_PREFIX_PATTERN,
 } from '../../../utils/comment-util';
-import {LABEL_TITLE_SCORE_PATTERN} from '../gr-message-scores/gr-message-scores';
 import {getAppContext} from '../../../services/app-context';
 import {pluralize} from '../../../utils/string-util';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
@@ -53,6 +52,7 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {userModelToken} from '../../../models/user/user-model';
 import {subscribe} from '../../lit/subscription-controller';
+import {LABEL_TITLE_SCORE_PATTERN} from '../../../utils/message-util';
 
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
diff --git a/polygerrit-ui/app/utils/message-util.ts b/polygerrit-ui/app/utils/message-util.ts
index fffe612..391e1d2 100644
--- a/polygerrit-ui/app/utils/message-util.ts
+++ b/polygerrit-ui/app/utils/message-util.ts
@@ -4,7 +4,26 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {MessageTag} from '../constants/constants';
-import {ChangeId, ChangeMessageInfo} from '../types/common';
+import {
+  AccountInfo,
+  ChangeId,
+  ChangeInfo,
+  ChangeMessage,
+  ChangeMessageInfo,
+  PatchSetNum,
+} from '../types/common';
+import {LabelExtreme, PATCH_SET_PREFIX_PATTERN} from './comment-util';
+import {hasOwnProperty} from './common-util';
+import {getVotingRange, StandardLabels} from './label-util';
+
+export const VOTE_RESET_TEXT = '0 (vote reset)';
+export const LABEL_TITLE_SCORE_PATTERN =
+  /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.:]?$/;
+
+export interface Score {
+  label?: string;
+  value?: string;
+}
 
 function getRevertChangeIdFromMessage(msg: ChangeMessageInfo): ChangeId {
   const REVERT_REGEX =
@@ -19,3 +38,76 @@
     .filter(m => m.tag === MessageTag.TAG_REVERT)
     .map(m => getRevertChangeIdFromMessage(m));
 }
+
+export function getScores(
+  message?: ChangeMessage,
+  labelExtremes?: LabelExtreme
+): Score[] {
+  if (!message || !message.message || !labelExtremes) {
+    return [];
+  }
+  const line = message.message.split('\n', 1)[0];
+  const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
+  if (!line.match(patchSetPrefix)) {
+    return [];
+  }
+  const scoresRaw = line.split(patchSetPrefix)[1];
+  if (!scoresRaw) {
+    return [];
+  }
+  return scoresRaw
+    .split(' ')
+    .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+    .filter(ms => ms && ms.length === 4 && hasOwnProperty(labelExtremes, ms[2]))
+    .map(ms => {
+      const label = ms?.[2];
+      const value = ms?.[1] === '-' ? VOTE_RESET_TEXT : ms?.[3];
+      return {label, value};
+    });
+}
+
+/**
+ * Extracts Code-Review votes from change messages, specifically those posted
+ * by the provided `account`.
+ * @param change The change info.
+ * @param account The account for which to extract the votes.
+ * @return A map where keys are patch set numbers and values are objects
+ *   containing the label ('Code-Review') and the numeric vote value.
+ */
+export function getCodeReviewVotesFromMessage(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): Map<PatchSetNum, Score> {
+  const codeReviewVotes = new Map<PatchSetNum, Score>();
+  if (!change?.messages || !change?.labels || !account) {
+    return codeReviewVotes;
+  }
+
+  const labelExtremes: LabelExtreme = {};
+  for (const labelName of Object.keys(change.labels)) {
+    const labelInfo = change.labels[labelName];
+    const range = getVotingRange(labelInfo);
+    if (range) {
+      labelExtremes[labelName] = range;
+    }
+  }
+
+  for (const message of change.messages) {
+    if (message.author?._account_id !== account._account_id) {
+      continue;
+    }
+    if (!message._revision_number) continue;
+
+    const scores = getScores(message as ChangeMessage, labelExtremes);
+    for (const score of scores) {
+      if (score.label === StandardLabels.CODE_REVIEW && score.value) {
+        const value = score.value === VOTE_RESET_TEXT ? '0' : score.value;
+        codeReviewVotes.set(message._revision_number, {
+          label: score.label,
+          value,
+        });
+      }
+    }
+  }
+  return codeReviewVotes;
+}
diff --git a/polygerrit-ui/app/utils/message-util_test.ts b/polygerrit-ui/app/utils/message-util_test.ts
index 64d765a..bed9376 100644
--- a/polygerrit-ui/app/utils/message-util_test.ts
+++ b/polygerrit-ui/app/utils/message-util_test.ts
@@ -3,50 +3,191 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {getRevertCreatedChangeIds} from './message-util';
+import {
+  getCodeReviewVotesFromMessage,
+  getRevertCreatedChangeIds,
+} from './message-util';
 import {assert} from '@open-wc/testing';
 import {MessageTag} from '../constants/constants';
-import {ChangeId, ReviewInputTag} from '../api/rest-api';
-import {createChangeMessage} from '../test/test-data-generators';
+import {
+  AccountInfo,
+  ChangeId,
+  ChangeInfo,
+  ChangeMessageInfo,
+  LabelNameToInfoMap,
+  PatchSetNum,
+  ReviewInputTag,
+} from '../api/rest-api';
+import {
+  createAccountWithId,
+  createChange,
+  createChangeMessage,
+  createDetailedLabelInfo,
+} from '../test/test-data-generators';
 
 suite('message-util tests', () => {
-  test('getRevertCreatedChangeIds', () => {
-    const messages = [
-      {
-        ...createChangeMessage(),
-        message:
-          'Created a revert of this change as If02ca1cd494579d6bb92a157bf1819e3689cd6b1',
-        tag: MessageTag.TAG_REVERT as ReviewInputTag,
-      },
-      {
-        ...createChangeMessage(),
-        message: 'Created a revert of this change as abc',
-        tag: undefined,
-      },
-    ];
+  suite('getRevertCreatedChangeIds', () => {
+    test('getRevertCreatedChangeIds', () => {
+      const messages = [
+        {
+          ...createChangeMessage(),
+          message:
+            'Created a revert of this change as If02ca1cd494579d6bb92a157bf1819e3689cd6b1',
+          tag: MessageTag.TAG_REVERT as ReviewInputTag,
+        },
+        {
+          ...createChangeMessage(),
+          message: 'Created a revert of this change as abc',
+          tag: undefined,
+        },
+      ];
 
-    assert.deepEqual(getRevertCreatedChangeIds(messages), [
-      'If02ca1cd494579d6bb92a157bf1819e3689cd6b1' as ChangeId,
-    ]);
+      assert.deepEqual(getRevertCreatedChangeIds(messages), [
+        'If02ca1cd494579d6bb92a157bf1819e3689cd6b1' as ChangeId,
+      ]);
+    });
+
+    test('getRevertCreatedChangeIds with extra spam', () => {
+      const messages = [
+        {
+          ...createChangeMessage(),
+          message:
+            'Created a revert of this change as IIf02ca1cd494579d6bb92a157bf1819e3689cd6b1',
+          tag: MessageTag.TAG_REVERT as ReviewInputTag,
+        },
+        {
+          ...createChangeMessage(),
+          message: 'Created a revert of this change as abc',
+          tag: undefined,
+        },
+      ];
+
+      assert.deepEqual(getRevertCreatedChangeIds(messages), [
+        'If02ca1cd494579d6bb92a157bf1819e3689cd6b1' as ChangeId,
+      ]);
+    });
   });
 
-  test('getRevertCreatedChangeIds with extra spam', () => {
-    const messages = [
-      {
-        ...createChangeMessage(),
-        message:
-          'Created a revert of this change as IIf02ca1cd494579d6bb92a157bf1819e3689cd6b1',
-        tag: MessageTag.TAG_REVERT as ReviewInputTag,
-      },
-      {
-        ...createChangeMessage(),
-        message: 'Created a revert of this change as abc',
-        tag: undefined,
-      },
-    ];
+  suite('getCodeReviewVotesFromMessage', () => {
+    const account1: AccountInfo = {
+      ...createAccountWithId(1),
+    };
+    const account2: AccountInfo = {
+      ...createAccountWithId(2),
+    };
 
-    assert.deepEqual(getRevertCreatedChangeIds(messages), [
-      'If02ca1cd494579d6bb92a157bf1819e3689cd6b1' as ChangeId,
-    ]);
+    const labels: LabelNameToInfoMap = {
+      'Code-Review': createDetailedLabelInfo(),
+    };
+
+    function createMessage(
+      author: AccountInfo,
+      message: string,
+      ps: number
+    ): ChangeMessageInfo {
+      return {
+        ...createChangeMessage(),
+        author,
+        message,
+        _revision_number: ps as PatchSetNum,
+      };
+    }
+
+    test('no messages', () => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        messages: [],
+        labels,
+      };
+      const actual = getCodeReviewVotesFromMessage(change, account1);
+      assert.equal(actual.size, 0);
+    });
+
+    test('no messages from account', () => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        messages: [createMessage(account2, 'Patch Set 1: Code-Review+1', 1)],
+        labels,
+      };
+      const actual = getCodeReviewVotesFromMessage(change, account1);
+      assert.equal(actual.size, 0);
+    });
+
+    test('one message with code review vote', () => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        messages: [createMessage(account1, 'Patch Set 1: Code-Review+1', 1)],
+        labels,
+      };
+      const actual = getCodeReviewVotesFromMessage(change, account1);
+      assert.deepEqual(
+        actual,
+        new Map([[1 as PatchSetNum, {label: 'Code-Review', value: '+1'}]])
+      );
+    });
+
+    test('vote reset', () => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        messages: [createMessage(account1, 'Patch Set 1: Code-Review-1', 1)],
+        labels,
+      };
+      const actual = getCodeReviewVotesFromMessage(change, account1);
+      assert.deepEqual(
+        actual,
+        new Map([[1 as PatchSetNum, {label: 'Code-Review', value: '-1'}]])
+      );
+    });
+
+    test('latest message wins for same patchset', () => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        messages: [
+          createMessage(account1, 'Patch Set 1: Code-Review-1', 1),
+          createMessage(account1, 'Patch Set 1: Code-Review+1', 1),
+        ],
+        labels,
+      };
+      const actual = getCodeReviewVotesFromMessage(change, account1);
+      assert.deepEqual(
+        actual,
+        new Map([[1 as PatchSetNum, {label: 'Code-Review', value: '+1'}]])
+      );
+    });
+
+    test('messages from different users', () => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        messages: [
+          createMessage(account1, 'Patch Set 1: Code-Review+1', 1),
+          createMessage(account2, 'Patch Set 1: Code-Review-1', 1),
+        ],
+        labels,
+      };
+      const actual = getCodeReviewVotesFromMessage(change, account1);
+      assert.deepEqual(
+        actual,
+        new Map([[1 as PatchSetNum, {label: 'Code-Review', value: '+1'}]])
+      );
+    });
+
+    test('messages for different patchsets', () => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        messages: [
+          createMessage(account1, 'Patch Set 1: Code-Review-1', 1),
+          createMessage(account1, 'Patch Set 2: Code-Review+1', 2),
+        ],
+        labels,
+      };
+      const actual = getCodeReviewVotesFromMessage(change, account1);
+      assert.deepEqual(
+        actual,
+        new Map([
+          [1 as PatchSetNum, {label: 'Code-Review', value: '-1'}],
+          [2 as PatchSetNum, {label: 'Code-Review', value: '+1'}],
+        ])
+      );
+    });
   });
 });