Merge "Merge branch 'stable-3.2'"
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
index 9f2e1f4..20f9c49 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -131,11 +131,11 @@
     return userFactory.create(authorId);
   }
 
-  /**
-   * Both this and {@code toEntitiesCommentRange} is needed since there are two Comment.Range
-   * entities, in different packages: {@code com.google.gerrit.entities.Comment.Range}, and {@code
-   * com.google.gerrit.extensions.Comment.Range}
-   */
+  private IdentifiedUser getAuthor(TestRobotCommentCreation robotCommentCreation) {
+    Account.Id authorId = robotCommentCreation.author().orElse(changeNotes.getChange().getOwner());
+    return userFactory.create(authorId);
+  }
+
   private static Comment.Range toCommentRange(TestRange range) {
     Comment.Range commentRange = new Range();
     commentRange.startLine = range.start().line();
@@ -145,19 +145,6 @@
     return commentRange;
   }
 
-  /**
-   * Both this and {@code toCommentRange} is needed since there are two Comment.Range entities, in
-   * different packages: {@code com.google.gerrit.entities.Comment.Range}, and {@code
-   * com.google.gerrit.extensions.Comment.Range}
-   */
-  private static com.google.gerrit.entities.Comment.Range toEntitiesCommentRange(TestRange range) {
-    return new com.google.gerrit.entities.Comment.Range(
-        range.start().line(),
-        range.start().charOffset(),
-        range.end().line(),
-        range.end().charOffset());
-  }
-
   private class CommentAdditionOp implements BatchUpdateOp {
     private String createdCommentUuid;
     private final TestCommentCreation commentCreation;
@@ -238,11 +225,6 @@
     }
   }
 
-  private IdentifiedUser getAuthor(TestRobotCommentCreation robotCommentCreation) {
-    Account.Id authorId = robotCommentCreation.author().orElse(changeNotes.getChange().getOwner());
-    return userFactory.create(authorId);
-  }
-
   private class RobotCommentAdditionOp implements BatchUpdateOp {
     private String createdRobotCommentUuid;
     private final TestRobotCommentCreation robotCommentCreation;
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 17a8ff4..39de43d 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -56,7 +55,6 @@
   private final PatchSetUtil psUtil;
   private final Provider<CommentJson> commentJson;
   private final PatchListCache patchListCache;
-  private final ChangeNotes.Factory changeNotesFactory;
 
   @Inject
   PutDraftComment(
@@ -65,15 +63,13 @@
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       Provider<CommentJson> commentJson,
-      PatchListCache patchListCache,
-      ChangeNotes.Factory changeNotesFactory) {
+      PatchListCache patchListCache) {
     this.updateFactory = updateFactory;
     this.delete = delete;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.commentJson = commentJson;
     this.patchListCache = patchListCache;
-    this.changeNotesFactory = changeNotesFactory;
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 0cdff4e..4f61d79 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -58,7 +58,6 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
@@ -67,7 +66,6 @@
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeEmailSender.Message;
-import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.sql.Timestamp;
@@ -99,8 +97,6 @@
   @Inject private ChangeOperations changeOperations;
   @Inject private AccountOperations accountOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
-  @Inject private CommentsUtil commentsUtil;
-  @Inject private TestCommentHelper testCommentHelper;
 
   private final Integer[] lines = {0, 1};
 
@@ -1649,12 +1645,6 @@
     gApi.changes().id(changeId).revision(revision).review(input);
   }
 
-  private void addComments(String changeId, CommentInput... commentInputs) throws Exception {
-    ReviewInput input = new ReviewInput();
-    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
-    gApi.changes().id(changeId).current().review(input);
-  }
-
   /**
    * All the commits, which contain the target comment before, should still contain the comment with
    * the updated message. All the other metas of the commits should be exactly the same.
@@ -1748,14 +1738,6 @@
     return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
   }
 
-  private CommentInfo addDraft(Change.Id changeId, String revId, DraftInput in) throws Exception {
-    return gApi.changes().id(changeId.get()).revision(revId).createDraft(in).get();
-  }
-
-  private CommentInfo addDraft(String changeId, DraftInput in) throws Exception {
-    return gApi.changes().id(changeId).current().createDraft(in).get();
-  }
-
   private CommentInfo addDraft(Change.Id changeId, DraftInput in) throws Exception {
     return gApi.changes().id(changeId.get()).current().createDraft(in).get();
   }
@@ -1765,10 +1747,6 @@
     gApi.changes().id(changeId).revision(revId).draft(uuid).update(in);
   }
 
-  private void updateDraft(String changeId, DraftInput in, String uuid) throws Exception {
-    gApi.changes().id(changeId).current().draft(uuid).update(in);
-  }
-
   private void updateDraft(Change.Id changeId, DraftInput in, String uuid) throws Exception {
     gApi.changes().id(changeId.get()).current().draft(uuid).update(in);
   }
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
deleted file mode 100644
index 174bbbb..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ /dev/null
@@ -1,303 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../shared/gr-account-chip/gr-account-chip.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-reviewer-list_html.js';
-import {
-  hasAttention,
-  isServiceUser,
-} from '../../../utils/account-util.js';
-
-/**
- * @extends PolymerElement
- */
-class GrReviewerList extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-reviewer-list'; }
-  /**
-   * Fired when the "Add reviewer..." button is tapped.
-   *
-   * @event show-reply-dialog
-   */
-
-  static get properties() {
-    return {
-      change: Object,
-      serverConfig: Object,
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      mutable: {
-        type: Boolean,
-        value: false,
-      },
-      reviewersOnly: {
-        type: Boolean,
-        value: false,
-      },
-      ccsOnly: {
-        type: Boolean,
-        value: false,
-      },
-
-      _displayedReviewers: {
-        type: Array,
-        value() { return []; },
-      },
-      _reviewers: {
-        type: Array,
-        value() { return []; },
-      },
-      _showInput: {
-        type: Boolean,
-        value: false,
-      },
-      _addLabel: {
-        type: String,
-        computed: '_computeAddLabel(ccsOnly)',
-      },
-      _hiddenReviewerCount: {
-        type: Number,
-        computed: '_computeHiddenCount(_reviewers, _displayedReviewers)',
-      },
-
-      // Used for testing.
-      _lastAutocompleteRequest: Object,
-      _xhrPromise: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_reviewersChanged(change.reviewers.*, change.owner, serverConfig)',
-    ];
-  }
-
-  /**
-   * Converts change.permitted_labels to an array of hashes of label keys to
-   * numeric scores.
-   * Example:
-   * [{
-   *   'Code-Review': ['-1', ' 0', '+1']
-   * }]
-   * will be converted to
-   * [{
-   *   label: 'Code-Review',
-   *   scores: [-1, 0, 1]
-   * }]
-   */
-  _permittedLabelsToNumericScores(labels) {
-    if (!labels) return [];
-    return Object.keys(labels).map(label => {
-      return {
-        label,
-        scores: labels[label].map(v => parseInt(v, 10)),
-      };
-    });
-  }
-
-  /**
-   * Returns hash of labels to max permitted score.
-   *
-   * @param {!Object} change
-   * @returns {!Object} labels to max permitted scores hash
-   */
-  _getMaxPermittedScores(change) {
-    return this._permittedLabelsToNumericScores(change.permitted_labels)
-        .map(({label, scores}) => {
-          return {
-            [label]: scores
-                .map(v => parseInt(v, 10))
-                .reduce((a, b) => Math.max(a, b))};
-        })
-        .reduce((acc, i) => Object.assign(acc, i), {});
-  }
-
-  /**
-   * Returns max permitted score for reviewer.
-   *
-   * @param {!Object} reviewer
-   * @param {!Object} change
-   * @param {string} label
-   * @return {number}
-   */
-  _getReviewerPermittedScore(reviewer, change, label) {
-    // Note (issue 7874): sometimes the "all" list is not included in change
-    // detail responses, even when DETAILED_LABELS is included in options.
-    if (!change.labels[label].all) { return NaN; }
-    const detailed = change.labels[label].all.filter(
-        ({_account_id}) => reviewer._account_id === _account_id).pop();
-    if (!detailed) {
-      return NaN;
-    }
-    if (detailed.hasOwnProperty('permitted_voting_range')) {
-      return detailed.permitted_voting_range.max;
-    } else if (detailed.hasOwnProperty('value')) {
-      // If preset, user can vote on the label.
-      return 0;
-    }
-    return NaN;
-  }
-
-  _computeVoteableText(reviewer, change) {
-    if (!change || !change.labels) { return ''; }
-    const maxScores = [];
-    const maxPermitted = this._getMaxPermittedScores(change);
-    for (const label of Object.keys(change.labels)) {
-      const maxScore =
-            this._getReviewerPermittedScore(reviewer, change, label);
-      if (isNaN(maxScore) || maxScore < 0) { continue; }
-      if (maxScore > 0 && maxScore === maxPermitted[label]) {
-        maxScores.push(`${label}: +${maxScore}`);
-      } else {
-        maxScores.push(`${label}`);
-      }
-    }
-    return maxScores.join(', ');
-  }
-
-  _reviewersChanged(changeRecord, owner, serverConfig) {
-    // Polymer 2: check for undefined
-    if ([changeRecord, owner, serverConfig].includes(undefined)) {
-      return;
-    }
-
-    let result = [];
-    const reviewers = changeRecord.base;
-    for (const key in reviewers) {
-      if (this.reviewersOnly && key !== 'REVIEWER') {
-        continue;
-      }
-      if (this.ccsOnly && key !== 'CC') {
-        continue;
-      }
-      if (key === 'REVIEWER' || key === 'CC') {
-        result = result.concat(reviewers[key]);
-      }
-    }
-    this._reviewers = result
-        .filter(reviewer => reviewer._account_id != owner._account_id)
-        // Sort order:
-        // 1. Human users in the attention set.
-        // 2. Other human users.
-        // 3. Service users.
-        .sort((r1, r2) => {
-          const a1 = hasAttention(serverConfig, r1, this.change) ? 1 : 0;
-          const a2 = hasAttention(serverConfig, r2, this.change) ? 1 : 0;
-          const s1 = isServiceUser(r1) ? -2 : 0;
-          const s2 = isServiceUser(r2) ? -2 : 0;
-          return a2 - a1 + s2 - s1;
-        });
-
-    if (this._reviewers.length > 8) {
-      this._displayedReviewers = this._reviewers.slice(0, 6);
-    } else {
-      this._displayedReviewers = this._reviewers;
-    }
-  }
-
-  _computeHiddenCount(reviewers, displayedReviewers) {
-    // Polymer 2: check for undefined
-    if ([reviewers, displayedReviewers].includes(undefined)) {
-      return undefined;
-    }
-
-    return reviewers.length - displayedReviewers.length;
-  }
-
-  _computeCanRemoveReviewer(reviewer, mutable) {
-    if (!mutable) { return false; }
-
-    let current;
-    for (let i = 0; i < this.change.removable_reviewers.length; i++) {
-      current = this.change.removable_reviewers[i];
-      if (current._account_id === reviewer._account_id ||
-          (!reviewer._account_id && current.email === reviewer.email)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  _handleRemove(e) {
-    e.preventDefault();
-    const target = dom(e).rootTarget;
-    if (!target.account) { return; }
-    const accountID = target.account._account_id || target.account.email;
-    this.disabled = true;
-    this._xhrPromise = this._removeReviewer(accountID).then(response => {
-      this.disabled = false;
-      if (!response.ok) { return response; }
-
-      const reviewers = this.change.reviewers;
-
-      for (const type of ['REVIEWER', 'CC']) {
-        reviewers[type] = reviewers[type] || [];
-        for (let i = 0; i < reviewers[type].length; i++) {
-          if (reviewers[type][i]._account_id == accountID ||
-          reviewers[type][i].email == accountID) {
-            this.splice('change.reviewers.' + type, i, 1);
-            break;
-          }
-        }
-      }
-    })
-        .catch(err => {
-          this.disabled = false;
-          throw err;
-        });
-  }
-
-  _handleAddTap(e) {
-    e.preventDefault();
-    const value = {};
-    if (this.reviewersOnly) {
-      value.reviewersOnly = true;
-    }
-    if (this.ccsOnly) {
-      value.ccsOnly = true;
-    }
-    this.dispatchEvent(new CustomEvent('show-reply-dialog', {
-      detail: {value},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _handleViewAll(e) {
-    this._displayedReviewers = this._reviewers;
-  }
-
-  _removeReviewer(id) {
-    return this.$.restAPI.removeChangeReviewer(this.change._number, id);
-  }
-
-  _computeAddLabel(ccsOnly) {
-    return ccsOnly ? 'Add CC' : 'Add reviewer';
-  }
-}
-
-customElements.define(GrReviewerList.is, GrReviewerList);
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
new file mode 100644
index 0000000..70e7ba7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -0,0 +1,339 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-reviewer-list_html';
+import {hasAttention, isServiceUser} from '../../../utils/account-util';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  ServerInfo,
+  LabelNameToValueMap,
+  AccountInfo,
+  ApprovalInfo,
+  Reviewers,
+  AccountId,
+  DetailedLabelInfo,
+} from '../../../types/common';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+export interface GrReviewerList {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-reviewer-list')
+export class GrReviewerList extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the "Add reviewer..." button is tapped.
+   *
+   * @event show-reply-dialog
+   */
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  serverConfig?: ServerInfo;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
+
+  @property({type: Boolean})
+  mutable = false;
+
+  @property({type: Boolean})
+  reviewersOnly = false;
+
+  @property({type: Boolean})
+  ccsOnly = false;
+
+  @property({type: Array})
+  _displayedReviewers: AccountInfo[] = [];
+
+  @property({type: Array})
+  _reviewers: AccountInfo[] = [];
+
+  @property({type: Boolean})
+  _showInput = false;
+
+  @property({type: Object})
+  _xhrPromise?: Promise<Response | undefined>;
+
+  @computed('ccsOnly')
+  get _addLabel() {
+    return this.ccsOnly ? 'Add CC' : 'Add reviewer';
+  }
+
+  @computed('_reviewers', '_displayedReviewers')
+  get _hiddenReviewerCount() {
+    // Polymer 2: check for undefined
+    if (
+      this._reviewers === undefined ||
+      this._displayedReviewers === undefined
+    ) {
+      return undefined;
+    }
+    return this._reviewers.length - this._displayedReviewers.length;
+  }
+
+  /**
+   * Converts change.permitted_labels to an array of hashes of label keys to
+   * numeric scores.
+   * Example:
+   * [{
+   *   'Code-Review': ['-1', ' 0', '+1']
+   * }]
+   * will be converted to
+   * [{
+   *   label: 'Code-Review',
+   *   scores: [-1, 0, 1]
+   * }]
+   */
+  _permittedLabelsToNumericScores(labels: LabelNameToValueMap | undefined) {
+    if (!labels) return [];
+    return Object.keys(labels).map(label => {
+      return {
+        label,
+        scores: labels[label].map(v => parseInt(v, 10)),
+      };
+    });
+  }
+
+  /**
+   * Returns hash of labels to max permitted score.
+   *
+   * @returns labels to max permitted scores hash
+   */
+  _getMaxPermittedScores(change: ChangeInfo) {
+    return this._permittedLabelsToNumericScores(change.permitted_labels)
+      .map(({label, scores}) => {
+        return {
+          [label]: scores.reduce((a, b) => Math.max(a, b)),
+        };
+      })
+      .reduce((acc, i) => Object.assign(acc, i), {});
+  }
+
+  /**
+   * Returns max permitted score for reviewer.
+   */
+  _getReviewerPermittedScore(
+    reviewer: AccountInfo,
+    change: ChangeInfo,
+    label: string
+  ) {
+    // Note (issue 7874): sometimes the "all" list is not included in change
+    // detail responses, even when DETAILED_LABELS is included in options.
+    if (!change.labels) {
+      return NaN;
+    }
+    const detailedLabel = change.labels[label] as DetailedLabelInfo;
+    if (!detailedLabel.all) {
+      return NaN;
+    }
+    const detailed = detailedLabel.all
+      .filter(
+        (approval: ApprovalInfo) =>
+          reviewer._account_id === approval._account_id
+      )
+      .pop();
+    if (!detailed) {
+      return NaN;
+    }
+    if (hasOwnProperty(detailed, 'permitted_voting_range')) {
+      if (!detailed.permitted_voting_range) return NaN;
+      return detailed.permitted_voting_range.max;
+    } else if (hasOwnProperty(detailed, 'value')) {
+      // If preset, user can vote on the label.
+      return 0;
+    }
+    return NaN;
+  }
+
+  _computeVoteableText(reviewer: AccountInfo, change: ChangeInfo) {
+    if (!change || !change.labels) {
+      return '';
+    }
+    const maxScores = [];
+    const maxPermitted = this._getMaxPermittedScores(change);
+    for (const label of Object.keys(change.labels)) {
+      const maxScore = this._getReviewerPermittedScore(reviewer, change, label);
+      if (isNaN(maxScore) || maxScore < 0) {
+        continue;
+      }
+      if (maxScore > 0 && maxScore === maxPermitted[label]) {
+        maxScores.push(`${label}: +${maxScore}`);
+      } else {
+        maxScores.push(`${label}`);
+      }
+    }
+    return maxScores.join(', ');
+  }
+
+  @observe('change.reviewers.*', 'change.owner', 'serverConfig')
+  _reviewersChanged(
+    changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
+    owner: AccountInfo,
+    serverConfig: ServerInfo
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      changeRecord === undefined ||
+      owner === undefined ||
+      serverConfig === undefined ||
+      this.change === undefined
+    ) {
+      return;
+    }
+    let result: AccountInfo[] = [];
+    const reviewers = changeRecord.base;
+    for (const key in reviewers) {
+      if (this.reviewersOnly && key !== 'REVIEWER') {
+        continue;
+      }
+      if (this.ccsOnly && key !== 'CC') {
+        continue;
+      }
+      if (key === 'REVIEWER' || key === 'CC') {
+        result = result.concat(reviewers[key]!);
+      }
+    }
+    this._reviewers = result
+      .filter(reviewer => reviewer._account_id !== owner._account_id)
+      // Sort order:
+      // 1. Human users in the attention set.
+      // 2. Other human users.
+      // 3. Service users.
+      .sort((r1, r2) => {
+        const a1 = hasAttention(serverConfig, r1, this.change!) ? 1 : 0;
+        const a2 = hasAttention(serverConfig, r2, this.change!) ? 1 : 0;
+        const s1 = isServiceUser(r1) ? -2 : 0;
+        const s2 = isServiceUser(r2) ? -2 : 0;
+        return a2 - a1 + s2 - s1;
+      });
+
+    if (this._reviewers.length > 8) {
+      this._displayedReviewers = this._reviewers.slice(0, 6);
+    } else {
+      this._displayedReviewers = this._reviewers;
+    }
+  }
+
+  _computeCanRemoveReviewer(reviewer: AccountInfo, mutable: boolean) {
+    if (
+      !mutable ||
+      this.change === undefined ||
+      this.change.removable_reviewers === undefined
+    ) {
+      return false;
+    }
+
+    let current;
+    for (let i = 0; i < this.change.removable_reviewers.length; i++) {
+      current = this.change.removable_reviewers[i];
+      if (
+        current._account_id === reviewer._account_id ||
+        (!reviewer._account_id && current.email === reviewer.email)
+      ) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  _handleRemove(e: Event) {
+    e.preventDefault();
+    const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
+    if (!target.account || !this.change) {
+      return;
+    }
+    const accountID = target.account._account_id;
+    this.disabled = true;
+    if (!accountID) return;
+    this._xhrPromise = this._removeReviewer(accountID)
+      .then((response: Response | undefined) => {
+        this.disabled = false;
+        if (!response || !response.ok) {
+          return response;
+        }
+        if (!this.change || !this.change.reviewers) return;
+        const reviewers: {[type: string]: AccountInfo[] | undefined} = this
+          .change!.reviewers;
+        for (const type of ['REVIEWER', 'CC']) {
+          reviewers[type] = reviewers[type] || [];
+          for (let i = 0; i < reviewers[type]!.length; i++) {
+            if (reviewers[type]![i]._account_id === accountID) {
+              this.splice('change.reviewers.' + type, i, 1);
+              break;
+            }
+          }
+        }
+        return;
+      })
+      .catch((err: Error) => {
+        this.disabled = false;
+        throw err;
+      });
+  }
+
+  _handleAddTap(e: Event) {
+    e.preventDefault();
+    const value = {
+      reviewersOnly: false,
+      ccsOnly: false,
+    };
+    if (this.reviewersOnly) {
+      value.reviewersOnly = true;
+    }
+    if (this.ccsOnly) {
+      value.ccsOnly = true;
+    }
+    this.dispatchEvent(
+      new CustomEvent('show-reply-dialog', {
+        detail: {value},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _handleViewAll() {
+    this._displayedReviewers = this._reviewers;
+  }
+
+  _removeReviewer(id: AccountId): Promise<Response | undefined> {
+    if (!this.change) return Promise.resolve(undefined);
+    return this.$.restAPI.removeChangeReviewer(this.change._number, id);
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
index 521945f..834cc43 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
@@ -163,21 +163,24 @@
     element.reviewersOnly = false;
     element._handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {}});
+    assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {
+      reviewersOnly: false,
+      ccsOnly: false,
+    }});
 
     element.reviewersOnly = true;
     element._handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
     assert.deepEqual(
         fireStub.lastCall.args[0].detail,
-        {value: {reviewersOnly: true}});
+        {value: {reviewersOnly: true, ccsOnly: false}});
 
     element.ccsOnly = true;
     element.reviewersOnly = false;
     element._handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
     assert.deepEqual(fireStub.lastCall.args[0].detail,
-        {value: {ccsOnly: true}});
+        {value: {ccsOnly: true, reviewersOnly: false}});
   });
 
   test('dont show all reviewers button with 4 reviewers', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
index ddf4c21..0bf6676 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
@@ -37,4 +37,11 @@
       details
     );
   }
+
+  reportLifeCycle(eventName: string, details?: EventDetails) {
+    return this.reporting.reportLifeCycle(
+      `${this.plugin.getPluginName()}-${eventName}`,
+      details
+    );
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
index e05dff3..1229641 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
@@ -57,5 +57,19 @@
           {}
       );
     });
+
+    test('redirect reportLifeCycle call to reportingService', () => {
+      sinon.spy(appContext.reportingService, 'reportLifeCycle');
+      reporting.reportLifeCycle('test', {});
+      assert.isTrue(appContext.reportingService.reportLifeCycle.called);
+      assert.equal(
+          appContext.reportingService.reportLifeCycle.lastCall.args[0],
+          'testplugin-test'
+      );
+      assert.deepEqual(
+          appContext.reportingService.reportLifeCycle.lastCall.args[1],
+          {}
+      );
+    });
   });
 });
\ No newline at end of file
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 90112b9..e3c2ce8 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -605,4 +605,8 @@
     changeNum: ChangeNum,
     messageId: ChangeMessageId
   ): Promise<Response>;
+  removeChangeReviewer(
+    changeNum: ChangeNum,
+    reviewerID: AccountId | GroupId
+  ): Promise<Response | undefined>;
 }
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index e131b4f..0c4bc8b 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -142,6 +142,10 @@
  */
 export type LabelInfo = QuickLabelInfo | DetailedLabelInfo;
 
+export type Reviewers = {
+  REVIEWER?: AccountInfo[];
+  CC?: AccountInfo[];
+};
 interface LabelCommonInfo {
   optional?: boolean; // not set if false
 }
@@ -211,10 +215,7 @@
   labels?: LabelNameToInfoMap;
   permitted_labels?: LabelNameToValueMap;
   removable_reviewers?: AccountInfo[];
-  reviewers?: {
-    REVIEWER?: AccountInfo[];
-    CC?: AccountInfo[];
-  };
+  reviewers?: Reviewers;
   pending_reviewers?: AccountInfo[];
   reviewer_updates?: ReviewerUpdateInfo[];
   messages?: ChangeMessageInfo[];