Add bulk vote flow

Adds logic for rendering the submit requirement votes for all the
selected changes.
A label is shown if all changes have that label.
The range of that label is the intersection of the range across all
changes.

Making the voting request and tracking progress will be done in a follow
up change.

Google-bug-id: b/216478680
Screenshot: https://imgur.com/a/6sRV1mN
Release-Notes: skip
Change-Id: I7a2638cc75d00c524751c4f751249309239eab88
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
index 9a1a0b4..ba2e161 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
@@ -13,6 +13,7 @@
 import '../../shared/gr-button/gr-button';
 import '../gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow';
 import '../gr-change-list-reviewer-flow/gr-change-list-reviewer-flow';
+import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow';
 
 /**
  * An action bar for the top of a <gr-change-list-section> element. Assumes it
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
new file mode 100644
index 0000000..fdf2b07
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -0,0 +1,155 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {customElement, query, state} from 'lit/decorators';
+import {LitElement, html, css} from 'lit';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {resolve} from '../../../models/dependency';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {subscribe} from '../../lit/subscription-controller';
+import {ChangeInfo, AccountInfo} from '../../../api/rest-api';
+import {
+  getTriggerVotes,
+  computeLabels,
+  mergeLabelMaps,
+  computeOrderedLabelValues,
+} from '../../../utils/label-util';
+import {getAppContext} from '../../../services/app-context';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../change/gr-label-score-row/gr-label-score-row';
+
+@customElement('gr-change-list-bulk-vote-flow')
+export class GrChangeListBulkVoteFlow extends LitElement {
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private readonly userModel = getAppContext().userModel;
+
+  @state() selectedChanges: ChangeInfo[] = [];
+
+  @query('#actionOverlay') actionOverlay!: GrOverlay;
+
+  @state() account?: AccountInfo;
+
+  static override get styles() {
+    return [
+      fontStyles,
+      css`
+        .scoresTable {
+          display: table;
+        }
+        .scoresTable.newSubmitRequirements {
+          table-layout: fixed;
+        }
+        gr-label-score-row:hover {
+          background-color: var(--hover-background-color);
+        }
+        gr-label-score-row {
+          display: table-row;
+        }
+        .heading-3 {
+          padding-left: var(--spacing-xl);
+          margin-bottom: var(--spacing-m);
+          margin-top: var(--spacing-l);
+          display: table-caption;
+        }
+        .heading-3:first-of-type {
+          margin-top: 0;
+        }
+      `,
+    ];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(
+      this,
+      this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => (this.selectedChanges = selectedChanges)
+    );
+    subscribe(
+      this,
+      this.userModel.account$,
+      account => (this.account = account)
+    );
+  }
+
+  override render() {
+    const permittedLabels = this.computePermittedLabels();
+    const labels = this.computeCommonLabels().filter(
+      label =>
+        permittedLabels?.[label.name] &&
+        permittedLabels?.[label.name].length > 0
+    );
+    // TODO: disable button if no label can be voted upon
+    return html`
+      <gr-button flatten @click=${() => this.actionOverlay.open()}
+        >Vote</gr-button
+      >
+      <gr-overlay id="actionOverlay" with-backdrop="">
+        <gr-dialog
+          @cancel=${() => this.actionOverlay.close()}
+          .cancelLabel=${'Close'}
+        >
+          <div slot="main">
+            <div class="scoresTable newSubmitRequirements">
+              <h3 class="heading-3">Submit requirements votes</h3>
+              ${labels.map(
+                label => html`<gr-label-score-row
+                  .label="${label}"
+                  .name="${label.name}"
+                  .labels="${labels}"
+                  .permittedLabels="${permittedLabels}"
+                  .orderedLabelValues="${computeOrderedLabelValues(
+                    permittedLabels
+                  )}"
+                ></gr-label-score-row>`
+              )}
+            </div>
+            <!-- TODO: Add section for trigger votes -->
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  // private but used in tests
+  computePermittedLabels() {
+    // Reduce method for empty array throws error if no initial value specified
+    if (this.selectedChanges.length === 0) return {};
+
+    return this.selectedChanges
+      .map(changes => changes.permitted_labels)
+      .reduce(mergeLabelMaps);
+  }
+
+  // private but used in tests
+  computeNonTriggerLabels(change: ChangeInfo) {
+    const triggerVotes = getTriggerVotes(change);
+    const labels = computeLabels(this.account, change).filter(
+      label => !triggerVotes.includes(label.name)
+    );
+    return labels;
+  }
+
+  // private but used in tests
+  // TODO: Remove Code Review label explicitly
+  computeCommonLabels() {
+    // Reduce method for empty array throws error if no initial value specified
+    if (this.selectedChanges.length === 0) return [];
+    return this.selectedChanges
+      .map(change => this.computeNonTriggerLabels(change))
+      .reduce((prev, current) =>
+        current.filter(label => prev.some(l => l.name === label.name))
+      );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-bulk-vote-flow': GrChangeListBulkVoteFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
new file mode 100644
index 0000000..55b1ac3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -0,0 +1,255 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../../test/common-test-setup-karma';
+import {GrChangeListBulkVoteFlow} from './gr-change-list-bulk-vote-flow';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+  LoadingState,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {waitUntilObserved, stubRestApi} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, LabelInfo} from '../../../api/rest-api';
+import {getAppContext} from '../../../services/app-context';
+import {fixture} from '@open-wc/testing-helpers';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {html} from 'lit';
+import {SinonStubbedMember} from 'sinon';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {
+  createChange,
+  createSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import './gr-change-list-bulk-vote-flow';
+
+const change1: ChangeInfo = {
+  ...createChange(),
+  _number: 1 as NumericChangeId,
+  permitted_labels: {
+    A: ['-1', '0', '+1', '+2'],
+    B: ['-1', '0'],
+    C: ['-1', '0'],
+    D: ['0'], // Does not exist on change2
+  },
+};
+const change2: ChangeInfo = {
+  ...createChange(),
+  _number: 2 as NumericChangeId,
+  permitted_labels: {
+    A: ['-1', '0', '+1', '+2'], // Intersects fully with change1
+    B: ['0', ' +1'], // Intersects with change1 on 0
+    C: ['+1', '+2'], // Does not intersect with change1 at all
+  },
+};
+
+suite('gr-change-list-bulk-vote-flow tests', () => {
+  let element: GrChangeListBulkVoteFlow;
+  let model: BulkActionsModel;
+  let getChangesStub: SinonStubbedMember<
+    RestApiService['getDetailedChangesWithActions']
+  >;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChangeNums$, selectedChangeNums =>
+      selectedChangeNums.includes(change._number)
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    model = new BulkActionsModel(getAppContext().restApiService);
+    getChangesStub = stubRestApi('getDetailedChangesWithActions');
+
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>`,
+          bulkActionsModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-change-list-bulk-vote-flow')!;
+    await element.updateComplete;
+  });
+
+  test('renders', async () => {
+    change1.labels = {
+      a: {value: null} as LabelInfo,
+      b: {value: null} as LabelInfo,
+      c: {value: null} as LabelInfo,
+    };
+    change1.submit_requirements = [
+      createSubmitRequirementResultInfo('label:a=MAX'),
+      createSubmitRequirementResultInfo('label:b=MAX'),
+      createSubmitRequirementResultInfo('label:c=MAX'),
+    ];
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `<gr-button
+        aria-disabled="false"
+        flatten=""
+        role="button"
+        tabindex="0"
+      >
+        Vote
+      </gr-button>
+      <gr-overlay
+        aria-hidden="true"
+        id="actionOverlay"
+        style="outline: none; display: none;"
+        tabindex="-1"
+        with-backdrop=""
+      >
+        <gr-dialog role="dialog">
+          <div slot="main">
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-3">Submit requirements votes</h3>
+            </div>
+          </div>
+        </gr-dialog>
+      </gr-overlay> `);
+  });
+
+  test('computePermittedLabels', async () => {
+    // {} if no change is selected
+    assert.deepEqual(element.computePermittedLabels(), {});
+
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.deepEqual(element.computePermittedLabels(), {
+      A: ['-1', '0', '+1', '+2'],
+      B: ['-1', '0'],
+      C: ['-1', '0'],
+      D: ['0'],
+    });
+
+    changes.push(change2);
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change2);
+    await element.updateComplete;
+
+    assert.deepEqual(element.computePermittedLabels(), {
+      A: ['-1', '0', '+1', '+2'],
+      B: ['0'],
+      C: [],
+    });
+  });
+
+  test('computeCommonLabels', async () => {
+    const change3: ChangeInfo = {
+      ...createChange(),
+      _number: 3 as NumericChangeId,
+    };
+    const change4: ChangeInfo = {
+      ...createChange(),
+      _number: 4 as NumericChangeId,
+    };
+
+    change1.labels = {
+      a: {value: null} as LabelInfo,
+      b: {value: null} as LabelInfo,
+      c: {value: null} as LabelInfo,
+    };
+    change1.submit_requirements = [
+      createSubmitRequirementResultInfo('label:a=MAX'),
+      createSubmitRequirementResultInfo('label:b=MAX'),
+      createSubmitRequirementResultInfo('label:c=MAX'),
+    ];
+
+    change2.labels = {
+      b: {value: null} as LabelInfo,
+      c: {value: null} as LabelInfo,
+      d: {value: null} as LabelInfo,
+    };
+    change2.submit_requirements = [
+      createSubmitRequirementResultInfo('label:b=MAX'),
+      createSubmitRequirementResultInfo('label:c=MAX'),
+      createSubmitRequirementResultInfo('label:d=MAX'),
+    ];
+
+    change3.labels = {
+      c: {value: null} as LabelInfo,
+      d: {value: null} as LabelInfo,
+      e: {value: null} as LabelInfo,
+    };
+    change3.submit_requirements = [
+      createSubmitRequirementResultInfo('label:c=MAX'),
+      createSubmitRequirementResultInfo('label:d=MAX'),
+      createSubmitRequirementResultInfo('label:e=MAX'),
+    ];
+
+    change4.labels = {
+      x: {value: null} as LabelInfo,
+      y: {value: null} as LabelInfo,
+      z: {value: null} as LabelInfo,
+    };
+    change4.submit_requirements = [
+      createSubmitRequirementResultInfo('label:x=MAX'),
+      createSubmitRequirementResultInfo('label:y=MAX'),
+      createSubmitRequirementResultInfo('label:z=MAX'),
+    ];
+
+    const changes: ChangeInfo[] = [change1, change2, change3, change4];
+    // Labels for each change are [a,b,c] [b,c,d] [c,d,e] [x,y,z]
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.deepEqual(element.computeCommonLabels(), [
+      {name: 'a', value: null},
+      {name: 'b', value: null},
+      {name: 'c', value: null},
+    ]);
+
+    await selectChange(change2);
+    await element.updateComplete;
+
+    // Intersection of [a,b,c] [b,c,d] is [b,c]
+    assert.deepEqual(element.computeCommonLabels(), [
+      {name: 'b', value: null},
+      {name: 'c', value: null},
+    ]);
+
+    await selectChange(change3);
+    await element.updateComplete;
+
+    // Intersection of [a,b,c] [b,c,d] [c,d,e] is [c]
+    assert.deepEqual(element.computeCommonLabels(), [{name: 'c', value: null}]);
+
+    await selectChange(change4);
+    await element.updateComplete;
+
+    // Intersection of [a,b,c] [b,c,d] [c,d,e] [x,y,z] is []
+    assert.deepEqual(element.computeCommonLabels(), []);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 7516eb6..30ff45f 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -31,11 +31,7 @@
 import {getAppContext} from '../../../services/app-context';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {classMap} from 'lit/directives/class-map';
-
-export interface Label {
-  name: string;
-  value: string | null;
-}
+import {Label} from '../../../utils/label-util';
 
 declare global {
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 923d3f0..3ca789d 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -25,12 +25,14 @@
   DetailedLabelInfo,
   LabelNameToValuesMap,
 } from '../../../types/common';
-import {GrLabelScoreRow, Label} from '../gr-label-score-row/gr-label-score-row';
+import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {getAppContext} from '../../../services/app-context';
 import {
   getTriggerVotes,
   showNewSubmitRequirements,
   computeLabels,
+  Label,
+  computeOrderedLabelValues,
 } from '../../../utils/label-util';
 import {ChangeStatus} from '../../../constants/constants';
 import {fontStyles} from '../../../styles/gr-font-styles';
@@ -162,7 +164,7 @@
             .name="${label.name}"
             .labels="${this.change?.labels}"
             .permittedLabels="${this.permittedLabels}"
-            .orderedLabelValues="${this.computeOrderedLabelValues()}"
+            .orderedLabelValues="${computeOrderedLabelValues()}"
           ></gr-label-score-row>`
         )}
     </div>`;
@@ -215,20 +217,6 @@
     const labelInfo = labels[labelName] as DetailedLabelInfo;
     return labelInfo.default_value;
   }
-
-  computeOrderedLabelValues() {
-    if (!this.permittedLabels) return;
-    const labels = Object.keys(this.permittedLabels);
-    const values: Set<number> = new Set();
-    for (const label of labels) {
-      for (const value of this.permittedLabels[label]) {
-        values.add(Number(value));
-      }
-    }
-
-    const orderedValues = Array.from(values.values()).sort((a, b) => a - b);
-    return orderedValues;
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
index a5d56cf..2751163 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
@@ -117,7 +117,7 @@
     assert.deepEqual(element.getLabelValues(false), {});
   });
 
-  test('_getVoteForAccount', () => {
+  test('getVoteForAccount', () => {
     const labelName = 'Code-Review';
     assert.strictEqual(
       getVoteForAccount(labelName, element.account, element.change),
@@ -125,11 +125,6 @@
     );
   });
 
-  test('computeOrderedLabelValues', () => {
-    const labelValues = element.computeOrderedLabelValues();
-    assert.deepEqual(labelValues, [-2, -1, 0, 1, 2]);
-  });
-
   suite('message', () => {
     test('shown when change is abandoned', async () => {
       element.change = {
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 9f61e1f..054021b 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -1138,7 +1138,9 @@
         ListChangesOption.CHANGE_ACTIONS,
         ListChangesOption.CURRENT_ACTIONS,
         ListChangesOption.CURRENT_REVISION,
-        ListChangesOption.DETAILED_LABELS
+        ListChangesOption.DETAILED_LABELS,
+        // TODO: remove this option and merge requirements from dashbaord req
+        ListChangesOption.SUBMIT_REQUIREMENTS
       )
     );
     return changeDetails;
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
index db893a1..fbfd152 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
@@ -30,7 +30,8 @@
     ListChangesOption.CHANGE_ACTIONS,
     ListChangesOption.CURRENT_ACTIONS,
     ListChangesOption.CURRENT_REVISION,
-    ListChangesOption.DETAILED_LABELS
+    ListChangesOption.DETAILED_LABELS,
+    ListChangesOption.SUBMIT_REQUIREMENTS
 );
 
 suite('gr-rest-api-service-impl tests', () => {
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index aa55ea2..e00923d 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -19,6 +19,7 @@
   isQuickLabelInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
+  LabelNameToValueMap,
 } from '../api/rest-api';
 import {FlagsService, KnownExperimentId} from '../services/flags/flags';
 import {
@@ -31,8 +32,18 @@
   VotingRangeInfo,
 } from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
-import {assertNever, unique} from './common-util';
-import {Label} from '../elements/change/gr-label-score-row/gr-label-score-row';
+import {assertNever, unique, hasOwnProperty} from './common-util';
+
+export interface Label {
+  name: string;
+  value: string | null;
+}
+
+// TODO(TS): add description to explain what this is after moving
+// gr-label-scores to ts
+export interface LabelValuesMap {
+  [key: number]: number;
+}
 
 // Name of the standard Code-Review label.
 export enum StandardLabels {
@@ -318,7 +329,7 @@
 export function getVoteForAccount(
   labelName: string,
   account?: AccountInfo,
-  change?: ChangeInfo
+  change?: ParsedChangeInfo | ChangeInfo
 ): string | null {
   const labels = change?.labels;
   if (!account || !labels) return null;
@@ -332,9 +343,41 @@
   return null;
 }
 
+export function computeOrderedLabelValues(
+  permittedLabels?: LabelNameToValueMap
+) {
+  if (!permittedLabels) return [];
+  const labels = Object.keys(permittedLabels);
+  const values: Set<number> = new Set();
+  for (const label of labels) {
+    for (const value of permittedLabels[label]) {
+      values.add(Number(value));
+    }
+  }
+
+  return Array.from(values.values()).sort((a, b) => a - b);
+}
+
+export function mergeLabelMaps(
+  a?: LabelNameToValueMap,
+  b?: LabelNameToValueMap
+): LabelNameToValueMap {
+  if (!a || !b) return {};
+  const ans: LabelNameToValueMap = {};
+  for (const key of Object.keys(a)) {
+    if (!hasOwnProperty(b, key)) continue;
+    ans[key] = mergeLabelValues(a[key], b[key]);
+  }
+  return ans;
+}
+
+export function mergeLabelValues(a: string[], b: string[]) {
+  return a.filter(value => b.includes(value));
+}
+
 export function computeLabels(
   account?: AccountInfo,
-  change?: ChangeInfo
+  change?: ParsedChangeInfo | ChangeInfo
 ): Label[] {
   if (!account) return [];
   const labelsObj = change?.labels;
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index ae341ed..456ce24 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -30,6 +30,8 @@
   labelCompare,
   LabelStatus,
   computeLabels,
+  mergeLabelMaps,
+  computeOrderedLabelValues,
 } from './label-util';
 import {
   AccountId,
@@ -240,6 +242,14 @@
     assert.equal(getRepresentativeValue(labelInfo), -2);
   });
 
+  test('computeOrderedLabelValues', () => {
+    const labelValues = computeOrderedLabelValues({
+      'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
+      Verified: ['-1', ' 0', '+1'],
+    });
+    assert.deepEqual(labelValues, [-2, -1, 0, 1, 2]);
+  });
+
   test('computeLabels', async () => {
     const accountId = 123 as AccountId;
     const account = createAccountWithId(accountId);
@@ -292,6 +302,112 @@
     ]);
   });
 
+  test('mergeLabelMaps', () => {
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        },
+        undefined
+      ),
+      {}
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(undefined, {
+        A: ['-1', '0', '+1', '+2'],
+        B: ['-1', '0'],
+        C: ['-1', '0'],
+        D: ['0'],
+      }),
+      {}
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        },
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        }
+      ),
+      {
+        A: ['-1', '0', '+1', '+2'],
+        B: ['-1', '0'],
+        C: ['-1', '0'],
+        D: ['0'],
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+        },
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          D: ['0'],
+        }
+      ),
+      {
+        A: ['-1', '0', '+1', '+2'],
+        B: ['-1', '0'],
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        },
+        {
+          A: [],
+          B: ['-1', '0'],
+          C: ['0', '+1'],
+          D: ['0'],
+        }
+      ),
+      {
+        A: [],
+        B: ['-1', '0'],
+        C: ['0'],
+        D: ['0'],
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+        },
+        {
+          X: ['-1', '0', '+1', '+2'],
+          Y: ['-1', '0'],
+          Z: ['0'],
+        }
+      ),
+      {}
+    );
+  });
+
   suite('extractAssociatedLabels()', () => {
     test('1 label', () => {
       const submitRequirement = createSubmitRequirementResultInfo();