Merge "Fix focus to commit message"
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 7b14612..55fe277 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
@@ -14,6 +14,7 @@
 import '../gr-change-list-reviewer-flow/gr-change-list-reviewer-flow';
 import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow';
 import '../gr-change-list-topic-flow/gr-change-list-topic-flow';
+import '../gr-change-list-hashtag-flow/gr-change-list-hashtag-flow';
 
 /**
  * An action bar for the top of a <gr-change-list-section> element. Assumes it
@@ -111,6 +112,7 @@
           <div class="actionButtons">
             <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
             <gr-change-list-topic-flow></gr-change-list-topic-flow>
+            <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
             <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
           </div>
         </div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
index bc73990..df68f5f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
@@ -66,6 +66,7 @@
           <div class="actionButtons">
             <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
             <gr-change-list-topic-flow></gr-change-list-topic-flow>
+            <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
             <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
           </div>
         </div>
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
index 07bc31d..9233dbc 100644
--- 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
@@ -77,10 +77,9 @@
           margin-top: var(--spacing-m);
         }
         .vote-type {
-          margin-bottom: var(--spacing-m);
+          margin-bottom: var(--spacing-s);
           margin-top: 0;
           display: table-caption;
-          font-weight: 600; /* TODO: create css variable for it */
         }
         .main-heading {
           margin-bottom: var(--spacing-m);
@@ -159,7 +158,7 @@
     permittedLabels?: LabelNameToValuesMap
   ) {
     return html` <div class="scoresTable newSubmitRequirements">
-      <h3 class="vote-type">${labels.length ? heading : nothing}</h3>
+      <h3 class="heading-4 vote-type">${labels.length ? heading : nothing}</h3>
       ${labels
         .filter(
           label =>
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
index 0447713..1372eb8 100644
--- 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
@@ -157,7 +157,7 @@
           </div>
           <div slot="main">
             <div class="newSubmitRequirements scoresTable">
-              <h3 class="vote-type">Submit requirements votes</h3>
+              <h3 class="heading-4 vote-type">Submit requirements votes</h3>
               <gr-label-score-row name="A"> </gr-label-score-row>
               <gr-label-score-row name="B"> </gr-label-score-row>
               <gr-label-score-row name="C"> </gr-label-score-row>
@@ -165,7 +165,7 @@
               </gr-label-score-row>
             </div>
             <div class="newSubmitRequirements scoresTable">
-              <h3 class="vote-type">Trigger Votes</h3>
+              <h3 class="heading-4 vote-type">Trigger Votes</h3>
               <gr-label-score-row name="change1OnlyTriggerLabelE">
               </gr-label-score-row>
             </div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
new file mode 100644
index 0000000..fdeb512
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -0,0 +1,379 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {ChangeInfo, Hashtag} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '@polymer/iron-dropdown/iron-dropdown';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {getAppContext} from '../../../services/app-context';
+import {notUndefined} from '../../../types/types';
+import {unique} from '../../../utils/common-util';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {when} from 'lit/directives/when';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+
+@customElement('gr-change-list-hashtag-flow')
+export class GrChangeListHashtagFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  @state() private hashtagToAdd: Hashtag = '' as Hashtag;
+
+  @state() private existingHashtagSuggestions: Hashtag[] = [];
+
+  @state() private loadingText?: string;
+
+  @state() private errorText?: string;
+
+  /** dropdown status is tracked here to lazy-load the inner DOM contents */
+  @state() private isDropdownOpen = false;
+
+  @state() private overallProgress: ProgressStatus = ProgressStatus.NOT_STARTED;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  private selectedExistingHashtags: Set<Hashtag> = new Set();
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      spinnerStyles,
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: 400px;
+          background-color: var(--dialog-background-color);
+          border-radius: 4px;
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l);
+        }
+        gr-autocomplete {
+          --border-color: var(--gray-800);
+        }
+        .footer {
+          display: flex;
+          justify-content: space-between;
+          align-items: baseline;
+        }
+        .buttons {
+          padding-top: var(--spacing-m);
+          display: flex;
+          justify-content: flex-end;
+          gap: var(--spacing-m);
+        }
+        .chips {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+        }
+        .chip {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-radius: 10px;
+          width: fit-content;
+          cursor: pointer;
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--gray-300);
+        }
+        .chip.selected {
+          color: var(--blue-800);
+          background-color: var(--blue-50);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          gap: var(--spacing-s);
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+      `,
+    ];
+  }
+
+  override connectedCallback(): void {
+    super.connectedCallback();
+    subscribe(
+      this,
+      this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+      }
+    );
+  }
+
+  override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
+    return html`
+      <gr-button
+        id="start-flow"
+        flatten
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
+        >Hashtag</gr-button
+      >
+      <iron-dropdown
+        .horizontalAlign=${'auto'}
+        .verticalAlign=${'auto'}
+        .verticalOffset=${24}
+        @opened-changed=${(e: CustomEvent) =>
+          (this.isDropdownOpen = e.detail.value)}
+      >
+        ${when(
+          this.isDropdownOpen,
+          () => html`
+            <div slot="dropdown-content">
+              ${when(
+                this.selectedChanges.some(change => change.hashtags?.length),
+                () => this.renderExistingHashtagsMode(),
+                () => this.renderNoExistingHashtagsMode()
+              )}
+            </div>
+          `
+        )}
+      </iron-dropdown>
+    `;
+  }
+
+  private renderExistingHashtagsMode() {
+    const hashtags = this.selectedChanges
+      .flatMap(change => change.hashtags ?? [])
+      .filter(notUndefined)
+      .filter(unique);
+    const removeDisabled =
+      this.selectedExistingHashtags.size === 0 ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    const applyToAllDisabled = this.selectedExistingHashtags.size !== 1;
+    return html`
+      <div class="chips">
+        ${hashtags.map(name => this.renderExistingHashtagChip(name))}
+      </div>
+      <div class="footer">
+        <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="apply-to-all-button"
+            flatten
+            ?disabled=${applyToAllDisabled}
+            @click=${this.applyHashtagToAll}
+            >Apply to all</gr-button
+          >
+          <gr-button
+            id="remove-hashtags-button"
+            flatten
+            ?disabled=${removeDisabled}
+            @click=${this.removeHashtags}
+            >Remove</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private renderExistingHashtagChip(name: Hashtag) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingHashtags.has(name),
+    };
+    return html`
+      <span
+        role="button"
+        aria-label=${name as string}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingHashtagSelected(name)}
+      >
+        ${name}
+      </span>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    if (this.overallProgress === ProgressStatus.RUNNING) {
+      return html`
+        <span class="loadingSpin"></span>
+        <span class="loadingText">${this.loadingText}</span>
+      `;
+    } else if (this.errorText !== undefined) {
+      return html`<div class="error">${this.errorText}</div>`;
+    }
+    return nothing;
+  }
+
+  private renderNoExistingHashtagsMode() {
+    const isCreateNewHashtagDisabled =
+      this.hashtagToAdd === '' ||
+      this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    const isApplyHashtagDisabled =
+      this.hashtagToAdd === '' ||
+      !this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    return html`
+      <!--
+        The .query function needs to be bound to this because lit's autobind
+        seems to work only for @event handlers.
+        'this.getHashtagSuggestions.bind(this)' gets in trouble with our linter
+        even though the bind is necessary here, so an anonymous function is used
+        instead.
+      -->
+      <gr-autocomplete
+        .text=${this.hashtagToAdd}
+        .query=${(query: string) => this.getHashtagSuggestions(query)}
+        show-blue-focus-border
+        placeholder="Type hashtag name to create or filter hashtags"
+        @text-changed=${(e: ValueChangedEvent<Hashtag>) =>
+          (this.hashtagToAdd = e.detail.value)}
+      ></gr-autocomplete>
+      <div class="footer">
+        <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="create-new-hashtag-button"
+            flatten
+            @click=${() => this.addHashtag('Creating hashtag...')}
+            .disabled=${isCreateNewHashtagDisabled}
+            >Create new hashtag</gr-button
+          >
+          <gr-button
+            id="apply-hashtag-button"
+            flatten
+            @click=${() => this.addHashtag('Applying hashtag...')}
+            .disabled=${isApplyHashtagDisabled}
+            >Apply</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private toggleDropdown() {
+    if (this.isDropdownOpen) {
+      this.closeDropdown();
+    } else {
+      this.reset();
+      this.openDropdown();
+    }
+  }
+
+  private reset() {
+    this.hashtagToAdd = '' as Hashtag;
+    this.selectedExistingHashtags = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
+  private async getHashtagSuggestions(
+    query: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getChangesWithSimilarHashtag(
+      query
+    );
+    this.existingHashtagSuggestions = (suggestions ?? [])
+      .flatMap(change => change.hashtags ?? [])
+      .filter(notUndefined)
+      .filter(unique);
+    return this.existingHashtagSuggestions.map(hashtag => {
+      return {name: hashtag, value: hashtag};
+    });
+  }
+
+  private removeHashtags() {
+    this.loadingText = `Removing hashtag${
+      this.selectedExistingHashtags.size > 1 ? 's' : ''
+    }...`;
+    this.trackPromises(
+      this.selectedChanges
+        .filter(
+          change =>
+            change.hashtags &&
+            change.hashtags.some(hashtag =>
+              this.selectedExistingHashtags.has(hashtag)
+            )
+        )
+        .map(change =>
+          this.restApiService.setChangeHashtag(change._number, {
+            remove: Array.from(this.selectedExistingHashtags.values()),
+          })
+        )
+    );
+  }
+
+  private applyHashtagToAll() {
+    this.loadingText = 'Applying hashtag to all';
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeHashtag(change._number, {
+          add: Array.from(this.selectedExistingHashtags.values()),
+        })
+      )
+    );
+  }
+
+  private addHashtag(loadingText: string) {
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeHashtag(change._number, {
+          add: [this.hashtagToAdd],
+        })
+      )
+    );
+  }
+
+  private async trackPromises(promises: Promise<Hashtag[]>[]) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      this.closeDropdown();
+      // TODO: fire reload of dashboard
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      // TODO: when some are rejected, show error and Cancel button
+    }
+  }
+
+  private toggleExistingHashtagSelected(name: Hashtag) {
+    if (this.selectedExistingHashtags.has(name)) {
+      this.selectedExistingHashtags.delete(name);
+    } else {
+      this.selectedExistingHashtags.add(name);
+    }
+    this.requestUpdate();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-hashtag-flow': GrChangeListHashtagFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
new file mode 100644
index 0000000..6910ae2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -0,0 +1,542 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html} from '@open-wc/testing-helpers';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import '../../../test/common-test-setup-karma';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import './gr-change-list-hashtag-flow';
+import type {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
+
+suite('gr-change-list-hashtag-flow tests', () => {
+  let element: GrChangeListHashtagFlow;
+  let model: BulkActionsModel;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  suite('dropdown closed', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+    });
+
+    test('skips dropdown render when closed', async () => {
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <gr-button
+          id="start-flow"
+          flatten=""
+          aria-disabled="false"
+          role="button"
+          tabindex="0"
+          >Hashtag</gr-button
+        >
+        <iron-dropdown
+          aria-disabled="false"
+          aria-hidden="true"
+          style="outline: none; display: none;"
+          vertical-align="auto"
+          horizontal-align="auto"
+        >
+        </iron-dropdown>
+      `);
+    });
+
+    test('dropdown hidden before flow button clicked', async () => {
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('flow button click shows dropdown', async () => {
+      const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+      button.click();
+      await element.updateComplete;
+
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isTrue(dropdown.opened);
+    });
+
+    test('flow button click when open hides dropdown', async () => {
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(() =>
+        Boolean(
+          queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+        )
+      );
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(
+        () =>
+          !queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+  });
+
+  suite('changes in existing hashtags', () => {
+    const changesWithHashtags: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        hashtags: ['hashtag1' as Hashtag],
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        hashtags: ['hashtag2' as Hashtag],
+      },
+    ];
+    let setChangeHashtagPromises: MockPromise<string>[];
+    let setChangeHashtagStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeHashtagPromises[0].resolve('foo');
+      setChangeHashtagPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithHashtags
+      );
+      setChangeHashtagPromises = [];
+      setChangeHashtagStub = stubRestApi('setChangeHashtag');
+      for (let i = 0; i < changesWithHashtags.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeHashtagPromises.push(promise);
+        setChangeHashtagStub
+          .withArgs(changesWithHashtags[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithHashtags);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+
+      // select changes
+      await selectChange(changesWithHashtags[0]);
+      await selectChange(changesWithHashtags[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await flush();
+    });
+
+    test('renders existing-hashtags flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <div class="chips">
+                <span role="button" aria-label="hashtag1" class="chip"
+                  >hashtag1</span
+                >
+                <span role="button" aria-label="hashtag2" class="chip"
+                  >hashtag2</span
+                >
+              </div>
+              <div class="footer">
+                <div class="loadingOrError"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="apply-to-all-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply to all</gr-button
+                  >
+                  <gr-button
+                    id="remove-hashtags-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Remove</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('remove single hashtag', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing hashtag...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different hashtag
+      assert.isTrue(setChangeHashtagStub.calledOnce);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {remove: ['hashtag1']},
+      ]);
+    });
+
+    test('remove multiple hashtags', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing hashtags...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different hashtag
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {remove: ['hashtag1', 'hashtag2']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithHashtags[1]._number,
+        {remove: ['hashtag1', 'hashtag2']},
+      ]);
+    });
+
+    test('can only apply a single hashtag', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('applies hashtag to all changes', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#apply-to-all-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying hashtag to all'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithHashtags[1]._number,
+        {add: ['hashtag1']},
+      ]);
+    });
+  });
+
+  suite('change have no existing hashtags', () => {
+    const changesWithNoHashtags: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+    let setChangeHashtagPromises: MockPromise<string>[];
+    let setChangeHashtagStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeHashtagPromises[0].resolve('foo');
+      setChangeHashtagPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithNoHashtags
+      );
+      setChangeHashtagPromises = [];
+      setChangeHashtagStub = stubRestApi('setChangeHashtag');
+      for (let i = 0; i < changesWithNoHashtags.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeHashtagPromises.push(promise);
+        setChangeHashtagStub
+          .withArgs(changesWithNoHashtags[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithNoHashtags);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+
+      // select changes
+      await selectChange(changesWithNoHashtags[0]);
+      await selectChange(changesWithNoHashtags[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await flush();
+    });
+
+    test('renders no-existing-hashtags flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <gr-autocomplete
+                placeholder="Type hashtag name to create or filter hashtags"
+                show-blue-focus-border=""
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="create-new-hashtag-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Create new hashtag</gr-button
+                  >
+                  <gr-button
+                    id="apply-hashtag-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('create new hashtag', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#create-new-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Creating hashtag...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithNoHashtags[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithNoHashtags[1]._number,
+        {add: ['foo']},
+      ]);
+    });
+
+    test('apply hashtag', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([{...createChange(), hashtags: ['foo' as Hashtag]}]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#create-new-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#apply-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying hashtag...'
+      );
+
+      await resolvePromises();
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithNoHashtags[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithNoHashtags[1]._number,
+        {add: ['foo']},
+      ]);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index 95e9c87..6d6722e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -24,7 +24,6 @@
 import {
   GrReviewerSuggestionsProvider,
   ReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
 } from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import '../../shared/gr-account-list/gr-account-list';
 import {getOverallStatus} from '../../../utils/bulk-flow-util';
@@ -34,11 +33,6 @@
 import {AccountInputDetail} from '../../shared/gr-account-list/gr-account-list';
 import '@polymer/iron-icon/iron-icon';
 
-const SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE = {
-  REVIEWER: SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
-  CC: SUGGESTIONS_PROVIDERS_USERS_TYPES.CC,
-};
-
 @customElement('gr-change-list-reviewer-flow')
 export class GrChangeListReviewerFlow extends LitElement {
   @state() private selectedChanges: ChangeInfo[] = [];
@@ -403,7 +397,7 @@
   ): ReviewerSuggestionsProvider {
     const suggestionsProvider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE[state],
+      state,
       this.serverConfig,
       this.isLoggedIn,
       ...this.selectedChanges.map(change => change._number)
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
index a9c6526..112e688 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -21,7 +21,6 @@
 import {ValueChangedEvent} from '../../../types/events';
 import {classMap} from 'lit/directives/class-map';
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
-import {pluralize} from '../../../utils/string-util';
 import {ProgressStatus} from '../../../constants/constants';
 import {allSettled} from '../../../utils/async-util';
 
@@ -31,8 +30,6 @@
 
   @state() private topicToAdd: TopicName = '' as TopicName;
 
-  @state() private selectedExistingTopics: Set<TopicName> = new Set();
-
   @state() private existingTopicSuggestions: TopicName[] = [];
 
   @state() private loadingText?: string;
@@ -46,6 +43,8 @@
 
   @query('iron-dropdown') private dropdown?: IronDropdownElement;
 
+  private selectedExistingTopics: Set<TopicName> = new Set();
+
   private getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
   private restApiService = getAppContext().restApiService;
@@ -123,14 +122,19 @@
   }
 
   override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
     return html`
-      <gr-button id="start-flow" flatten @click=${this.toggleDropdown}
+      <gr-button
+        id="start-flow"
+        flatten
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
         >Topic</gr-button
       >
       <iron-dropdown
         .horizontalAlign=${'auto'}
         .verticalAlign=${'auto'}
-        .verticalOffset=${24 /* roughly line height in pixels */}
+        .verticalOffset=${24}
         @opened-changed=${(e: CustomEvent) =>
           (this.isDropdownOpen = e.detail.value)}
       >
@@ -191,6 +195,8 @@
     };
     return html`
       <span
+        role="button"
+        aria-label=${name as string}
         class=${classMap(chipClasses)}
         @click=${() => this.toggleExistingTopicSelected(name)}
       >
@@ -260,18 +266,30 @@
 
   private toggleDropdown() {
     if (this.isDropdownOpen) {
-      this.isDropdownOpen = false;
-      this.dropdown?.close();
+      this.closeDropdown();
     } else {
-      this.topicToAdd = '' as TopicName;
-      this.selectedExistingTopics = new Set();
-      this.overallProgress = ProgressStatus.NOT_STARTED;
-      this.errorText = undefined;
-      this.isDropdownOpen = true;
-      this.dropdown?.open();
+      this.reset();
+      this.openDropdown();
     }
   }
 
+  private reset() {
+    this.topicToAdd = '' as TopicName;
+    this.selectedExistingTopics = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
   private async getTopicSuggestions(
     query: string
   ): Promise<AutocompleteSuggestion[]> {
@@ -288,10 +306,9 @@
   }
 
   private removeTopics() {
-    this.loadingText = `Removing ${pluralize(
-      this.selectedExistingTopics.size,
-      'topic'
-    )}...`;
+    this.loadingText = `Removing topic${
+      this.selectedExistingTopics.size > 1 ? 's' : ''
+    }...`;
     this.trackPromises(
       this.selectedChanges
         .filter(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
index 677aeb4..fd78479 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -210,8 +210,12 @@
           >
             <div slot="dropdown-content">
               <div class="chips">
-                <span class="chip">topic1</span>
-                <span class="chip">topic2</span>
+                <span role="button" aria-label="topic1" class="chip"
+                  >topic1</span
+                >
+                <span role="button" aria-label="topic2" class="chip"
+                  >topic2</span
+                >
               </div>
               <div class="footer">
                 <div class="loadingOrError"></div>
@@ -254,7 +258,7 @@
 
       assert.equal(
         queryAndAssert(element, '.loadingText').textContent,
-        'Removing 1 topic...'
+        'Removing topic...'
       );
 
       await resolvePromises();
@@ -277,7 +281,7 @@
 
       assert.equal(
         queryAndAssert(element, '.loadingText').textContent,
-        'Removing 2 topics...'
+        'Removing topics...'
       );
 
       await resolvePromises();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 9af14f5..95ad376 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -173,6 +173,8 @@
 
   @state() private queryTopic?: AutocompleteQuery;
 
+  @state() private queryHashtag?: AutocompleteQuery;
+
   private restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -182,6 +184,7 @@
   constructor() {
     super();
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
+    this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
 
   static override styles = [
@@ -689,6 +692,8 @@
               .readOnly=${this.hashtagReadOnly}
               @changed=${this.handleHashtagChanged}
               showAsEditPencil
+              autocomplete
+              .query=${this.queryHashtag}
             ></gr-editable-label>
           `
         )}
@@ -1192,6 +1197,22 @@
       );
   }
 
+  private getHashtagSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
+    return this.restApiService
+      .getChangesWithSimilarHashtag(input)
+      .then(response =>
+        (response ?? [])
+          .flatMap(change => change.hashtags ?? [])
+          .filter(notUndefined)
+          .filter(unique)
+          .map(hashtag => {
+            return {name: hashtag, value: hashtag};
+          })
+      );
+  }
+
   private showNewSubmitRequirements() {
     return showNewSubmitRequirements(this.flagsService, this.change);
   }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index c35b6b3..c3eea40b 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -27,10 +27,7 @@
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
 import '../../../styles/shared-styles';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GrReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {getAppContext} from '../../../services/app-context';
 import {
   ChangeStatus,
@@ -1491,7 +1488,7 @@
     const jsonPromise = this.restApiService.getResponseObject(response.clone());
     return jsonPromise.then((parsed: ParsedJSON) => {
       const result = parsed as ReviewResult;
-      // Only perform custom error handling for 400s and a parseable
+      // Only perform custom error handling for 400s and a parsable
       // ReviewResult response.
       if (response.status === 400 && result && result.reviewers) {
         const errors: string[] = [];
@@ -2090,7 +2087,7 @@
     if (!change) return;
     const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
+      ReviewerState.REVIEWER,
       this.serverConfig,
       this.isLoggedIn,
       change._number
@@ -2102,7 +2099,7 @@
     if (!change) return;
     const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.CC,
+      ReviewerState.CC,
       this.serverConfig,
       this.isLoggedIn,
       change._number
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 4493e8d..ce5ef4f 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -292,7 +292,7 @@
             class="attentionIcon"
             icon="gr-icons:attention"
           ></iron-icon>
-          <span> ${this.computePronoun()} turn to take this action. </span>
+          <span> ${this.computePronoun()} turn to take action. </span>
           <a
             href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
             target="_blank"
diff --git a/polygerrit-ui/app/models/dependency.ts b/polygerrit-ui/app/models/dependency.ts
index f39fa32..135c1aa 100644
--- a/polygerrit-ui/app/models/dependency.ts
+++ b/polygerrit-ui/app/models/dependency.ts
@@ -102,7 +102,7 @@
  * Type Safety
  * ---
  *
- * Dependency injection is guaranteed npmtype-safe by construction due to the
+ * Dependency injection is guaranteed type-safe by construction due to the
  * typing of the token used to tie together dependency providers and dependency
  * consumers.
  *
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 95e7f2a..5cb57aa 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -33,13 +33,7 @@
 import {allSettled, isFulfilled} from '../../utils/async-util';
 import {notUndefined} from '../../types/types';
 import {accountKey} from '../../utils/account-util';
-
-// TODO(TS): enum name doesn't follow typescript style guide rules
-// Rename it
-export enum SUGGESTIONS_PROVIDERS_USERS_TYPES {
-  REVIEWER = 'reviewers',
-  CC = 'ccs',
-}
+import {ReviewerState} from '../../api/rest-api';
 
 export interface ReviewerSuggestionsProvider {
   getSuggestions(input: string): Promise<Suggestion[]>;
@@ -55,7 +49,7 @@
 
   constructor(
     private restApi: RestApiService,
-    private type: SUGGESTIONS_PROVIDERS_USERS_TYPES,
+    private type: ReviewerState.REVIEWER | ReviewerState.CC,
     private config: ServerInfo | undefined,
     private loggedIn: boolean,
     ...changeNumbers: NumericChangeId[]
@@ -112,8 +106,8 @@
   private getSuggestionsForChange(
     changeNumber: NumericChangeId,
     input: string
-  ): Promise<Suggestion[] | undefined> {
-    return this.type === SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
+  ): Promise<SuggestedReviewerInfo[] | undefined> {
+    return this.type === ReviewerState.REVIEWER
       ? this.restApi.getChangeSuggestedReviewers(changeNumber, input)
       : this.restApi.getChangeSuggestedCCs(changeNumber, input);
   }
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
index 3d1a15f..3dc30dd 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
@@ -16,10 +16,7 @@
  */
 
 import '../../test/common-test-setup-karma';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from './gr-reviewer-suggestions-provider';
+import {GrReviewerSuggestionsProvider} from './gr-reviewer-suggestions-provider';
 import {getAppContext} from '../../services/app-context';
 import {stubRestApi} from '../../test/test-utils';
 import {
@@ -27,6 +24,7 @@
   GroupId,
   GroupName,
   NumericChangeId,
+  ReviewerState,
 } from '../../api/rest-api';
 import {
   SuggestedReviewerAccountInfo,
@@ -70,7 +68,7 @@
     ]);
     provider = new GrReviewerSuggestionsProvider(
       getAppContext().restApiService,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
+      ReviewerState.REVIEWER,
       createServerInfo(),
       true,
       change._number
@@ -90,7 +88,7 @@
     // not logged in
     provider = new GrReviewerSuggestionsProvider(
       getAppContext().restApiService,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
+      ReviewerState.REVIEWER,
       createServerInfo(),
       false,
       change._number
@@ -108,7 +106,7 @@
       .resolves([suggestion2, suggestion3]);
     provider = new GrReviewerSuggestionsProvider(
       getAppContext().restApiService,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
+      ReviewerState.REVIEWER,
       createServerInfo(),
       true,
       ...[change._number, 43 as NumericChangeId]
@@ -128,7 +126,7 @@
       .resolves([suggestion2, suggestion3]);
     provider = new GrReviewerSuggestionsProvider(
       getAppContext().restApiService,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.CC,
+      ReviewerState.CC,
       createServerInfo(),
       true,
       ...[change._number, 43 as NumericChangeId]
@@ -172,7 +170,7 @@
 
     provider = new GrReviewerSuggestionsProvider(
       getAppContext().restApiService,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
+      ReviewerState.REVIEWER,
       {
         ...createServerInfo(),
         user: {
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 8e6a147..2b4fc60 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
@@ -1817,7 +1817,7 @@
   }
 
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
-    const query = [`intopic:"${topic}"`].join(' ');
+    const query = `intopic:"${topic}"`;
     return this._restApiHelper.fetchJSON({
       url: '/changes/',
       params: {q: query},
@@ -1825,6 +1825,17 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
+  getChangesWithSimilarHashtag(
+    hashtag: string
+  ): Promise<ChangeInfo[] | undefined> {
+    const query = `inhashtag:"${hashtag}"`;
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params: {q: query},
+      anonymizedUrl: '/changes/inhashtag:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
   getReviewedFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index e727216..0ea561f 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -634,6 +634,9 @@
     }
   ): Promise<ChangeInfo[] | undefined>;
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
+  getChangesWithSimilarHashtag(
+    hashtag: string
+  ): Promise<ChangeInfo[] | undefined>;
 
   hasPendingDiffDrafts(): number;
   awaitPendingDiffDrafts(): Promise<void>;
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index d91b438..ae8545a 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -280,6 +280,9 @@
   getChangesWithSimilarTopic(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
+  getChangesWithSimilarHashtag(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
   getConfig(): Promise<ServerInfo | undefined> {
     return Promise.resolve(createServerInfo());
   },