Add support to update author and committer identities in change edit UI

Add an editable label for change author and committer in the change edit
UI. Also, show the author and committer account chips in edit UI when
the author and committer emails are the same as owner email. The
identity input box accepts the pattern 'name <email>'.

Screenshots: https://imgur.com/a/Dk9YG8O

Change-Id: Ic6d86e748f7678c7bcd4a0d89a2df75472225e92
Release-Notes: change edit UI supports updating author and committer identities
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index 7d6d17d..0af614d 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -280,7 +280,7 @@
     filterActive = false
   ) {
     return this.restApiService
-      .getSuggestedAccounts(
+      .queryAccounts(
         input,
         SUGGESTIONS_LIMIT,
         canSee,
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
index a3c7bbd..c16a108 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
@@ -93,7 +93,7 @@
       },
     ];
 
-    stubRestApi('getSuggestedAccounts').callsFake(input => {
+    stubRestApi('queryAccounts').callsFake(input => {
       if (input.startsWith('test')) {
         return Promise.resolve([
           {
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 634fbf8..2ac92af 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
@@ -59,7 +59,12 @@
   isSectionSet,
   DisplayRules,
 } from '../../../utils/change-metadata-util';
-import {fireAlert, fire, fireReload} from '../../../utils/event-util';
+import {
+  fireAlert,
+  fire,
+  fireReload,
+  fireError,
+} from '../../../utils/event-util';
 import {
   EditRevisionInfo,
   isDefined,
@@ -89,6 +94,7 @@
 import {changeModelToken} from '../../../models/change/change-model';
 import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 import {truncatePath} from '../../../utils/path-list-util';
+import {accountEmail, getDisplayName} from '../../../utils/display-name-util';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -134,6 +140,8 @@
 
   @state() revertedChange?: ChangeInfo;
 
+  @state() editMode = false;
+
   @state() account?: AccountDetailInfo;
 
   @state() revision?: RevisionInfo | EditRevisionInfo;
@@ -209,6 +217,11 @@
       () => this.getRelatedChangesModel().revertingChange$,
       revertingChange => (this.revertedChange = revertingChange)
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().editMode$,
+      x => (this.editMode = x)
+    );
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
     this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
@@ -231,6 +244,9 @@
         gr-weblink {
           display: block;
         }
+        gr-account-chip {
+          display: inline;
+        }
         gr-account-chip[disabled],
         gr-linked-chip[disabled] {
           opacity: 0;
@@ -471,6 +487,21 @@
             circle-shape
           ></gr-vote-chip>
         </gr-account-chip>
+        ${when(
+          this.editMode &&
+            (role === ChangeRole.AUTHOR || role === ChangeRole.COMMITTER),
+          () => html`
+            <gr-editable-label
+              id="${role}-edit-label"
+              placeholder="Update ${name}"
+              @changed=${(e: CustomEvent<string>) =>
+                this.handleIdentityChanged(e, role)}
+              showAsEditPencil
+              autocomplete
+              .query=${(text: string) => this.getIdentitySuggestions(text)}
+            ></gr-editable-label>
+          `
+        )}
       </span>
     </section>`;
   }
@@ -865,6 +896,31 @@
   }
 
   // private but used in test
+  async handleIdentityChanged(e: CustomEvent<string>, role: ChangeRole) {
+    assertIsDefined(this.change, 'change');
+    const input = e.detail.length ? e.detail.trim() : undefined;
+    if (!input?.length) return;
+    const reg = /(\w+.*)\s<(\S+@\S+.\S+)>/;
+    const [, name, email] = input.match(reg) ?? [];
+    if (!name || !email) {
+      fireError(
+        this,
+        'Invalid input format, valid identity format is "FullName <user@example.com>"'
+      );
+      return;
+    }
+    fireAlert(this, 'Saving identity and reloading ...');
+    await this.restApiService.updateIdentityInChangeEdit(
+      this.change._number,
+      name,
+      email,
+      role.toUpperCase()
+    );
+    fire(this, 'hide-alert', {});
+    fireReload(this);
+  }
+
+  // private but used in test
   computeTopicReadOnly() {
     return !this.mutable || !this.change?.actions?.topic?.enabled;
   }
@@ -1140,7 +1196,7 @@
     if (
       role === ChangeRole.AUTHOR &&
       rev.commit?.author &&
-      this.change.owner.email !== rev.commit.author.email
+      (this.editMode || this.change.owner.email !== rev.commit.author.email)
     ) {
       return rev.commit.author;
     }
@@ -1148,10 +1204,12 @@
     if (
       role === ChangeRole.COMMITTER &&
       rev.commit?.committer &&
-      this.change.owner.email !== rev.commit.committer.email &&
-      !(
-        rev.uploader?.email && rev.uploader.email === rev.commit.committer.email
-      )
+      (this.editMode ||
+        (this.change.owner.email !== rev.commit.committer.email &&
+          !(
+            rev.uploader?.email &&
+            rev.uploader.email === rev.commit.committer.email
+          )))
     ) {
       return rev.commit.committer;
     }
@@ -1227,6 +1285,25 @@
       );
   }
 
+  private async getIdentitySuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getAccountSuggestions(input);
+    if (!suggestions) return [];
+    const identitySuggestions: AutocompleteSuggestion[] = [];
+    suggestions.forEach(account => {
+      const name = getDisplayName(this.serverConfig, account);
+      const emails: string[] = [];
+      account.email && emails.push(account.email);
+      account.secondary_emails && emails.push(...account.secondary_emails);
+      emails.forEach(email => {
+        const identity = name + ' ' + accountEmail(email);
+        identitySuggestions.push({name: identity});
+      });
+    });
+    return identitySuggestions;
+  }
+
   private computeVoteForRole(role: ChangeRole) {
     const reviewer = this.getNonOwnerRole(role);
     if (reviewer && isAccount(reviewer)) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 93ef3e3..309ad2d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -407,6 +407,15 @@
         element.change = change;
         assert.isNotOk(element.getNonOwnerRole(ChangeRole.COMMITTER));
       });
+
+      test('getNonOwnerRole returns committer with same email as owner in edit mode', () => {
+        // Set the committer email to be the same as the owner.
+        change!.revisions.rev1.commit!.committer.email =
+          'abc@def' as EmailAddress;
+        element.change = change;
+        element.editMode = true;
+        assert.isOk(element.getNonOwnerRole(ChangeRole.COMMITTER));
+      });
     });
 
     suite('role=author', () => {
@@ -430,6 +439,14 @@
         element.change = change;
         assert.isNotOk(element.getNonOwnerRole(ChangeRole.AUTHOR));
       });
+
+      test('getNonOwnerRole returns author with same email as owner in edit mode', () => {
+        // Set the author email to be the same as the owner.
+        change!.revisions.rev1.commit!.author.email = 'abc@def' as EmailAddress;
+        element.change = change;
+        element.editMode = true;
+        assert.isOk(element.getNonOwnerRole(ChangeRole.AUTHOR));
+      });
     });
   });
 
@@ -938,6 +955,35 @@
     });
   });
 
+  test('update author identity', async () => {
+    const change = createParsedChange();
+    element.change = change;
+    element.editMode = true;
+    await element.updateComplete;
+    const updateIdentityInChangeEditStub = stubRestApi(
+      'updateIdentityInChangeEdit'
+    ).resolves();
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    queryAndAssert(element, '#author-edit-label').dispatchEvent(
+      new CustomEvent('changed', {detail: 'user <user@example.com>'})
+    );
+    assert.isTrue(
+      updateIdentityInChangeEditStub.calledWith(
+        42 as NumericChangeId,
+        'user',
+        'user@example.com',
+        'AUTHOR'
+      )
+    );
+    await updateIdentityInChangeEditStub.lastCall.returnValue;
+    await waitUntilCalled(alertStub, 'alertStub');
+    assert.deepEqual(alertStub.lastCall.args[0].detail, {
+      message: 'Saving identity and reloading ...',
+      showDismiss: true,
+    });
+  });
+
   test('editTopic', async () => {
     element.account = createAccountDetailWithId();
     element.change = {
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
index 8b4b52f..48f5a05 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -172,7 +172,7 @@
       return Promise.resolve([]);
     }
     return this.restApiService
-      .getSuggestedAccounts(
+      .queryAccounts(
         expression,
         MAX_AUTOCOMPLETE_RESULTS,
         /* canSee=*/ undefined,
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
index 7e3b896..0551f23 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
@@ -25,7 +25,7 @@
   });
 
   test('Autocompletes accounts', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
+    stubRestApi('queryAccounts').callsFake(() =>
       Promise.resolve([
         {
           name: 'fred',
@@ -39,7 +39,7 @@
   });
 
   test('Inserts self as option when valid', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
+    stubRestApi('queryAccounts').callsFake(() =>
       Promise.resolve([
         {
           name: 'fred',
@@ -60,7 +60,7 @@
   });
 
   test('Inserts me as option when valid', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
+    stubRestApi('queryAccounts').callsFake(() =>
       Promise.resolve([
         {
           name: 'fred',
@@ -118,7 +118,7 @@
   });
 
   test('Autocompletes accounts with no email', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
+    stubRestApi('queryAccounts').callsFake(() =>
       Promise.resolve([{name: 'fred'}])
     );
     return element.fetchAccounts('owner', 'fr').then(s => {
@@ -127,7 +127,7 @@
   });
 
   test('Autocompletes accounts with email', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
+    stubRestApi('queryAccounts').callsFake(() =>
       Promise.resolve([{email: 'fred@goog.co' as EmailAddress}])
     );
     return element.fetchAccounts('owner', 'fr').then(s => {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 7972cc1..7f70911 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -606,7 +606,7 @@
   // TODO(dhruvsri): merge with getAccountSuggestions in account-util
   async computeReviewerSuggestions(): Promise<Item[]> {
     return (
-      (await this.restApiService.getSuggestedAccounts(
+      (await this.restApiService.queryAccounts(
         this.currentSearchString ?? '',
         /* number= */ 15,
         this.changeNum,
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 4aef66e..d84f5a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -59,7 +59,7 @@
       // updated.
       const listenerStub = sinon.stub();
       element.addEventListener('text-changed', listenerStub);
-      stubRestApi('getSuggestedAccounts').returns(
+      stubRestApi('queryAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
           createAccountWithEmail('abcdef@google.com'),
@@ -93,7 +93,7 @@
     });
 
     test('mention selector opens when previous char is \n', async () => {
-      stubRestApi('getSuggestedAccounts').returns(
+      stubRestApi('queryAccounts').returns(
         Promise.resolve([
           {
             ...createAccountWithEmail('abc@google.com'),
@@ -130,7 +130,7 @@
 
     test('mention suggestions cleared before request returns', async () => {
       const promise = mockPromise<Item[]>();
-      stubRestApi('getSuggestedAccounts').returns(promise);
+      stubRestApi('queryAccounts').returns(promise);
       element.textarea!.focus();
       await waitUntil(() => element.textarea!.focused === true);
 
@@ -164,7 +164,7 @@
     test('mention dropdown shows suggestion for latest text', async () => {
       const promise1 = mockPromise<Item[]>();
       const promise2 = mockPromise<Item[]>();
-      const suggestionStub = stubRestApi('getSuggestedAccounts');
+      const suggestionStub = stubRestApi('queryAccounts');
       suggestionStub.returns(promise1);
       element.textarea!.focus();
       await waitUntil(() => element.textarea!.focused === true);
@@ -221,7 +221,7 @@
     });
 
     test('selecting mentions from dropdown', async () => {
-      stubRestApi('getSuggestedAccounts').returns(
+      stubRestApi('queryAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
           createAccountWithEmail('abcdef@google.com'),
@@ -254,7 +254,7 @@
       const listenerStub = sinon.stub();
       element.addEventListener('text-changed', listenerStub);
       const resetSpy = sinon.spy(element, 'resetDropdown');
-      stubRestApi('getSuggestedAccounts').returns(
+      stubRestApi('queryAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
           createAccountWithEmail('abcdef@google.com'),
@@ -348,7 +348,7 @@
     });
 
     test('mention dropdown is cleared if @ is deleted', async () => {
-      stubRestApi('getSuggestedAccounts').returns(
+      stubRestApi('queryAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
           createAccountWithEmail('abcdef@google.com'),
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 bbb47c2..18ba3c1 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
@@ -1778,7 +1778,7 @@
     });
   }
 
-  async getSuggestedAccounts(
+  async queryAccounts(
     inputVal: string,
     n?: number,
     canSee?: NumericChangeId,
@@ -1815,6 +1815,19 @@
     }) as Promise<AccountInfo[] | undefined>;
   }
 
+  getAccountSuggestions(inputVal: string): Promise<AccountInfo[] | undefined> {
+    const params: QueryAccountsParams = {suggest: undefined, q: ''};
+    inputVal = inputVal?.trim() ?? '';
+    if (inputVal.length > 0) {
+      params.q = inputVal;
+    }
+    if (!params.q) return Promise.resolve([]);
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/',
+      params,
+    }) as Promise<AccountInfo[] | undefined>;
+  }
+
   addChangeReviewer(
     changeNum: NumericChangeId,
     reviewerID: AccountId | EmailAddress | GroupId
@@ -2313,6 +2326,21 @@
     });
   }
 
+  updateIdentityInChangeEdit(
+    changeNum: NumericChangeId,
+    name: string,
+    email: string,
+    type: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      endpoint: '/edit:identity',
+      body: {name, email, type},
+      reportEndpointAsIs: true,
+    });
+  }
+
   deleteChangeCommitMessage(
     changeNum: NumericChangeId,
     messageId: ChangeMessageId
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
index fe52529..1018d00 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -347,7 +347,7 @@
     assert.isFalse(element._cache.has(cacheKey));
   });
 
-  suite('getAccountSuggestions', () => {
+  suite('queryAccounts', () => {
     let fetchStub: sinon.SinonStub;
     const testProject = 'testproject';
     const testChangeNumber = 341682;
@@ -362,7 +362,7 @@
     });
 
     test('url with just email', async () => {
-      await element.getSuggestedAccounts('bro');
+      await element.queryAccounts('bro');
       assert.isTrue(fetchStub.calledOnce);
       assert.equal(
         fetchStub.firstCall.args[0].url,
@@ -371,7 +371,7 @@
     });
 
     test('url with email and canSee changeId', async () => {
-      await element.getSuggestedAccounts(
+      await element.queryAccounts(
         'bro',
         undefined,
         testChangeNumber as NumericChangeId
@@ -384,7 +384,7 @@
     });
 
     test('url with email and canSee changeId and isActive', async () => {
-      await element.getSuggestedAccounts(
+      await element.queryAccounts(
         'bro',
         undefined,
         testChangeNumber as NumericChangeId,
@@ -398,6 +398,18 @@
     });
   });
 
+  test('getAccountSuggestions using suggest query param', () => {
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response());
+    element.getAccountSuggestions('user');
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.firstCall.args[0].url,
+      `${getBaseUrl()}/accounts/?suggest&q=user`
+    );
+  });
+
   test('getAccount when resp is undefined clears cache', async () => {
     const cacheKey = '/accounts/self/detail';
     const account = createAccountDetailWithId();
@@ -758,6 +770,27 @@
     });
   });
 
+  test('updateIdentityInChangeEdit', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const change_num = 1 as NumericChangeId;
+    const name = 'user';
+    const email = 'user@example.com';
+    const type = 'AUTHOR';
+    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+    await element.updateIdentityInChangeEdit(change_num, name, email, type);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(
+      sendStub.lastCall.args[0].url,
+      '/changes/test~1/edit:identity'
+    );
+    assert.deepEqual(sendStub.lastCall.args[0].body, {
+      email: 'user@example.com',
+      name: 'user',
+      type: 'AUTHOR',
+    });
+  });
+
   test('deleteChangeCommitMessage', async () => {
     element._projectLookup = {1: Promise.resolve('test' as RepoName)};
     const change_num = 1 as NumericChangeId;
@@ -1046,18 +1079,18 @@
     assert(fetchStub.called);
   });
 
-  test('getSuggestedAccounts does not return fetchJSON', async () => {
+  test('queryAccounts does not return fetchJSON', async () => {
     const fetchJSONSpy = sinon.spy(element._restApiHelper, 'fetchJSON');
-    const accts = await element.getSuggestedAccounts('');
+    const accts = await element.queryAccounts('');
     assert.isFalse(fetchJSONSpy.called);
     assert.equal(accts!.length, 0);
   });
 
-  test('fetchJSON gets called by getSuggestedAccounts', async () => {
+  test('fetchJSON gets called by queryAccounts', async () => {
     const fetchJSONStub = sinon
       .stub(element._restApiHelper, 'fetchJSON')
       .resolves();
-    await element.getSuggestedAccounts('own');
+    await element.queryAccounts('own');
     assert.deepEqual(fetchJSONStub.lastCall.args[0].params, {
       q: '"own"',
       o: 'DETAILS',
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 546f06a0..a147e26 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
@@ -165,13 +165,14 @@
    * Request list of accounts via https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#query-account
    * Operators defined here https://gerrit-review.googlesource.com/Documentation/user-search-accounts.html#_search_operators
    */
-  getSuggestedAccounts(
+  queryAccounts(
     input: string,
     n?: number,
     canSee?: NumericChangeId,
     filterActive?: boolean,
     errFn?: ErrorCallback
   ): Promise<AccountInfo[] | undefined>;
+  getAccountSuggestions(input: string): Promise<AccountInfo[] | undefined>;
   getSuggestedGroups(
     input: string,
     project?: RepoName,
@@ -821,6 +822,13 @@
     message: string
   ): Promise<Response>;
 
+  updateIdentityInChangeEdit(
+    changeNum: NumericChangeId,
+    name: string,
+    email: string,
+    type: string
+  ): Promise<Response | undefined>;
+
   getChangeCommitInfo(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
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 7969264..5928ebf 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -413,7 +413,10 @@
   getRobotCommentFixPreview(): Promise<FilePathToDiffInfoMap | undefined> {
     return Promise.resolve({});
   },
-  getSuggestedAccounts(): Promise<AccountInfo[] | undefined> {
+  queryAccounts(): Promise<AccountInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getAccountSuggestions(): Promise<AccountInfo[] | undefined> {
     return Promise.resolve([]);
   },
   getSuggestedGroups(): Promise<GroupNameToGroupInfoMap | undefined> {
@@ -554,4 +557,7 @@
   setRepoHead(): Promise<Response> {
     return Promise.resolve(new Response());
   },
+  updateIdentityInChangeEdit(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
 };
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
index 850509f..5f56e51 100644
--- a/polygerrit-ui/app/utils/display-name-util.ts
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -63,7 +63,7 @@
     .join(' ');
 }
 
-function accountEmail(email?: string) {
+export function accountEmail(email?: string) {
   if (typeof email !== 'undefined') {
     return '<' + email + '>';
   }