Merge "Scroll to comment in diff when file is unchanged"
diff --git a/Documentation/logs.txt b/Documentation/logs.txt
index 43a8e62..6624366 100644
--- a/Documentation/logs.txt
+++ b/Documentation/logs.txt
@@ -8,6 +8,12 @@
 at server startup and then daily at 11pm and
 link:config-gerrit.html#log.rotate[rotated] every midnight.
 
+== Time format
+
+For all timestamps the format `[yyyy-MM-dd'T'HH:mm:ss,SSSXXX]` is used.
+This format is both link:https://www.w3.org/TR/NOTE-datetime[ISO 8601] and
+link:https://tools.ietf.org/html/rfc3339[RFC3339] compatible.
+
 == Logs
 
 The following logs can be written.
@@ -32,10 +38,7 @@
 * `username`: the username used by the client for authentication. "-" for
   anonymous requests.
 * `[date:time]`: The date and time stamp of the HTTP request.
-  The time that the request was received. The format is until Gerrit 3.1
-  `[dd/MMM/yyyy:HH:mm:ss.SSS ZZZZ]`. For Gerrit 3.2 or newer
-  link:https://www.w3.org/TR/NOTE-datetime[ISO 8601 format] `[yyyy-MM-dd'T'HH:mm:ss,SSSZ]`
-  is used for all timestamps.
+  The time that the request was received.
 * `request`: The request line from the client is given in double quotes.
 ** the HTTP method used by the client.
 ** the resource the client requested.
@@ -63,10 +66,6 @@
 Log format:
 
 * `[date time]`: The time that the request was received.
-  The format is until Gerrit 3.1 `[yyyy-mm-dd HH:mm:ss.SSS ZZZZ]`.
-  For Gerrit 3.2 or newer
-  link:https://www.w3.org/TR/NOTE-datetime[ISO 8601 format] `[yyyy-MM-dd'T'HH:mm:ss,SSSZ]`
-  is used for all timestamps.
 * `sessionid`: hexadecimal session identifier, all requests of the
   same connection share the same sessionid. Gerrit does not support multiplexing multiple
   sessions on the same connection. Grep the log file using the sessionid as filter to
@@ -135,10 +134,6 @@
 Log format:
 
 * `[date time]`: The time that the request was received.
-  The format is until Gerrit 3.1 `[yyyy-mm-dd HH:mm:ss.SSS ZZZZ]`.
-  For Gerrit 3.2 or newer
-  link:https://www.w3.org/TR/NOTE-datetime[ISO 8601 format] `[yyyy-MM-dd'T'HH:mm:ss,SSSZ]`
-  is used for all timestamps.
 * `[thread name]`: : name of the Java thread executing the request.
 * `level`: log level (ERROR, WARN, INFO, DEBUG).
 * `logger`: name of the logger.
@@ -153,10 +148,6 @@
 Log format:
 
 * `[date time]`: The time that the request was received.
-  The format is until Gerrit 3.1 `[yyyy-mm-dd HH:mm:ss.SSS ZZZZ]`.
-  For Gerrit 3.2 or newer
-  link:https://www.w3.org/TR/NOTE-datetime[ISO 8601 format] `[yyyy-MM-dd'T'HH:mm:ss,SSSZ]`
-  is used for all timestamps.
 * `level`: log level (ERROR, WARN, INFO, DEBUG).
 * `message`: log message.
 
diff --git a/java/com/google/gerrit/util/logging/LogTimestampFormatter.java b/java/com/google/gerrit/util/logging/LogTimestampFormatter.java
index 9637b8b..cf071de 100644
--- a/java/com/google/gerrit/util/logging/LogTimestampFormatter.java
+++ b/java/com/google/gerrit/util/logging/LogTimestampFormatter.java
@@ -24,7 +24,7 @@
 
 /** Formatter for timestamps used in log entries. */
 public class LogTimestampFormatter {
-  public static final String TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";
+  public static final String TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX";
 
   private final DateTimeFormatter dateFormatter;
   private final ZoneOffset timeOffset;
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index a6301f9..783fa49 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.time.LocalDateTime;
+import java.time.ZoneOffset;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -181,7 +182,7 @@
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comments. Comments should be more than 1 second apart as NoteDb only supports second
     // precision.
-    LocalDateTime now = LocalDateTime.now();
+    LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
     String rootCommentUuid = newComment(patchset1Id).resolved().createdOn(now).create();
     String childComment1Uuid =
         newComment(patchset1Id)
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
deleted file mode 100644
index 259a302..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
+++ /dev/null
@@ -1,138 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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 '../../../styles/gr-table-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-account-link/gr-account-link.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-group-audit-log_html.js';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
-
-/**
- * @extends PolymerElement
- */
-class GrGroupAuditLog extends ListViewMixin(GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-group-audit-log'; }
-
-  static get properties() {
-    return {
-      groupId: String,
-      _auditLog: Array,
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: 'Audit Log'},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._getAuditLogs();
-  }
-
-  _getAuditLogs() {
-    if (!this.groupId) { return ''; }
-
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-
-    return this.$.restAPI.getGroupAuditLog(this.groupId, errFn)
-        .then(auditLog => {
-          if (!auditLog) {
-            this._auditLog = [];
-            return;
-          }
-          this._auditLog = auditLog;
-          this._loading = false;
-        });
-  }
-
-  _status(item) {
-    return item.disabled ? 'Disabled' : 'Enabled';
-  }
-
-  itemType(type) {
-    let item;
-    switch (type) {
-      case 'ADD_GROUP':
-      case 'ADD_USER':
-        item = 'Added';
-        break;
-      case 'REMOVE_GROUP':
-      case 'REMOVE_USER':
-        item = 'Removed';
-        break;
-      default:
-        item = '';
-    }
-    return item;
-  }
-
-  _isGroupEvent(type) {
-    return GROUP_EVENTS.indexOf(type) !== -1;
-  }
-
-  _computeGroupUrl(group) {
-    if (group && group.url && group.id) {
-      return GerritNav.getUrlForGroup(group.id);
-    }
-
-    return '';
-  }
-
-  _getIdForUser(account) {
-    return account._account_id ? ' (' + account._account_id + ')' : '';
-  }
-
-  _getNameForGroup(group) {
-    if (group && group.name) {
-      return group.name;
-    } else if (group && group.id) {
-      // The URL encoded id of the member
-      return decodeURIComponent(group.id);
-    }
-
-    return '';
-  }
-}
-
-customElements.define(GrGroupAuditLog.is, GrGroupAuditLog);
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
new file mode 100644
index 0000000..f7cffac
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -0,0 +1,159 @@
+/**
+ * @license
+ * Copyright (C) 2017 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 '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-account-link/gr-account-link';
+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-group-audit-log_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  GroupInfo,
+  AccountInfo,
+  EncodedGroupId,
+  GroupAuditEventInfo,
+} from '../../../types/common';
+
+const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
+
+export interface GrGroupAuditLog {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-group-audit-log')
+export class GrGroupAuditLog extends ListViewMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  groupId?: EncodedGroupId;
+
+  @property({type: Array})
+  _auditLog?: GroupAuditEventInfo[];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: 'Audit Log'},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._getAuditLogs();
+  }
+
+  _getAuditLogs() {
+    if (!this.groupId) {
+      return '';
+    }
+
+    const errFn: ErrorCallback = response => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+
+    return this.$.restAPI
+      .getGroupAuditLog(this.groupId, errFn)
+      .then(auditLog => {
+        if (!auditLog) {
+          this._auditLog = [];
+          return;
+        }
+        this._auditLog = auditLog;
+        this._loading = false;
+      });
+  }
+
+  itemType(type: string) {
+    let item;
+    switch (type) {
+      case 'ADD_GROUP':
+      case 'ADD_USER':
+        item = 'Added';
+        break;
+      case 'REMOVE_GROUP':
+      case 'REMOVE_USER':
+        item = 'Removed';
+        break;
+      default:
+        item = '';
+    }
+    return item;
+  }
+
+  _isGroupEvent(type: string) {
+    return GROUP_EVENTS.indexOf(type) !== -1;
+  }
+
+  _computeGroupUrl(group: GroupInfo) {
+    if (group && group.url && group.id) {
+      return GerritNav.getUrlForGroup(group.id);
+    }
+
+    return '';
+  }
+
+  _getIdForUser(account: AccountInfo) {
+    return account._account_id ? ` (${account._account_id})` : '';
+  }
+
+  _getNameForGroup(group: GroupInfo) {
+    if (group && group.name) {
+      return group.name;
+    } else if (group && group.id) {
+      // The URL encoded id of the member
+      return decodeURIComponent(group.id);
+    }
+
+    return '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-group-audit-log': GrGroupAuditLog;
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
deleted file mode 100644
index 0fb57d3..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ /dev/null
@@ -1,310 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-subpage-styles.js';
-import '../../../styles/gr-table-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.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-group-members_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-
-const SUGGESTIONS_LIMIT = 15;
-const SAVING_ERROR_TEXT = 'Group may not exist, or you may not have '+
-    'permission to add it';
-
-const URL_REGEX = '^(?:[a-z]+:)?//';
-
-/**
- * @extends PolymerElement
- */
-class GrGroupMembers extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-group-members'; }
-
-  static get properties() {
-    return {
-      groupId: Number,
-      _groupMemberSearchId: String,
-      _groupMemberSearchName: String,
-      _includedGroupSearchId: String,
-      _includedGroupSearchName: String,
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _groupName: String,
-      _groupMembers: Object,
-      _includedGroups: Object,
-      _itemName: String,
-      _itemType: String,
-      _queryMembers: {
-        type: Function,
-        value() {
-          return input => this._getAccountSuggestions(input);
-        },
-      },
-      _queryIncludedGroup: {
-        type: Function,
-        value() {
-          return input => this._getGroupSuggestions(input);
-        },
-      },
-      _groupOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadGroupDetails();
-
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: 'Members'},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _loadGroupDetails() {
-    if (!this.groupId) { return; }
-
-    const promises = [];
-
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-
-    return this.$.restAPI.getGroupConfig(this.groupId, errFn)
-        .then(config => {
-          if (!config || !config.name) { return Promise.resolve(); }
-
-          this._groupName = config.name;
-
-          promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-            this._isAdmin = !!isAdmin;
-          }));
-
-          promises.push(this.$.restAPI.getIsGroupOwner(config.name)
-              .then(isOwner => {
-                this._groupOwner = !!isOwner;
-              }));
-
-          promises.push(this.$.restAPI.getGroupMembers(config.name).then(
-              members => {
-                this._groupMembers = members;
-              }));
-
-          promises.push(this.$.restAPI.getIncludedGroup(config.name)
-              .then(includedGroup => {
-                this._includedGroups = includedGroup;
-              }));
-
-          return Promise.all(promises).then(() => {
-            this._loading = false;
-          });
-        });
-  }
-
-  _computeLoadingClass(loading) {
-    return loading ? 'loading' : '';
-  }
-
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _computeGroupUrl(url) {
-    if (!url) { return; }
-
-    const r = new RegExp(URL_REGEX, 'i');
-    if (r.test(url)) {
-      return url;
-    }
-
-    // For GWT compatibility
-    if (url.startsWith('#')) {
-      return getBaseUrl() + url.slice(1);
-    }
-    return getBaseUrl() + url;
-  }
-
-  _handleSavingGroupMember() {
-    return this.$.restAPI.saveGroupMember(this._groupName,
-        this._groupMemberSearchId).then(config => {
-      if (!config) {
-        return;
-      }
-      this.$.restAPI.getGroupMembers(this._groupName).then(members => {
-        this._groupMembers = members;
-      });
-      this._groupMemberSearchName = '';
-      this._groupMemberSearchId = '';
-    });
-  }
-
-  _handleDeleteConfirm() {
-    this.$.overlay.close();
-    if (this._itemType === 'member') {
-      return this.$.restAPI.deleteGroupMember(this._groupName,
-          this._itemId)
-          .then(itemDeleted => {
-            if (itemDeleted.status === 204) {
-              this.$.restAPI.getGroupMembers(this._groupName)
-                  .then(members => {
-                    this._groupMembers = members;
-                  });
-            }
-          });
-    } else if (this._itemType === 'includedGroup') {
-      return this.$.restAPI.deleteIncludedGroup(this._groupName,
-          this._itemId)
-          .then(itemDeleted => {
-            if (itemDeleted.status === 204 || itemDeleted.status === 205) {
-              this.$.restAPI.getIncludedGroup(this._groupName)
-                  .then(includedGroup => {
-                    this._includedGroups = includedGroup;
-                  });
-            }
-          });
-    }
-  }
-
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
-  }
-
-  _handleDeleteMember(e) {
-    const id = e.model.get('item._account_id');
-    const name = e.model.get('item.name');
-    const username = e.model.get('item.username');
-    const email = e.model.get('item.email');
-    const item = username || name || email || id;
-    if (!item) {
-      return '';
-    }
-    this._itemName = item;
-    this._itemId = id;
-    this._itemType = 'member';
-    this.$.overlay.open();
-  }
-
-  _handleSavingIncludedGroups() {
-    return this.$.restAPI.saveIncludedGroup(this._groupName,
-        this._includedGroupSearchId.replace(/\+/g, ' '), (errResponse, err) => {
-          if (errResponse) {
-            if (errResponse.status === 404) {
-              this.dispatchEvent(new CustomEvent('show-alert', {
-                detail: {message: SAVING_ERROR_TEXT},
-                bubbles: true,
-                composed: true,
-              }));
-              return errResponse;
-            }
-            throw Error(err.statusText);
-          }
-          throw err;
-        })
-        .then(config => {
-          if (!config) {
-            return;
-          }
-          this.$.restAPI.getIncludedGroup(this._groupName)
-              .then(includedGroup => {
-                this._includedGroups = includedGroup;
-              });
-          this._includedGroupSearchName = '';
-          this._includedGroupSearchId = '';
-        });
-  }
-
-  _handleDeleteIncludedGroup(e) {
-    const id = decodeURIComponent(e.model.get('item.id')).replace(/\+/g, ' ');
-    const name = e.model.get('item.name');
-    const item = name || id;
-    if (!item) { return ''; }
-    this._itemName = item;
-    this._itemId = id;
-    this._itemType = 'includedGroup';
-    this.$.overlay.open();
-  }
-
-  _getAccountSuggestions(input) {
-    if (input.length === 0) { return Promise.resolve([]); }
-    return this.$.restAPI.getSuggestedAccounts(
-        input, SUGGESTIONS_LIMIT).then(accounts => {
-      const accountSuggestions = [];
-      let nameAndEmail;
-      if (!accounts) { return []; }
-      for (const key in accounts) {
-        if (!accounts.hasOwnProperty(key)) { continue; }
-        if (accounts[key].email !== undefined) {
-          nameAndEmail = accounts[key].name +
-                ' <' + accounts[key].email + '>';
-        } else {
-          nameAndEmail = accounts[key].name;
-        }
-        accountSuggestions.push({
-          name: nameAndEmail,
-          value: accounts[key]._account_id,
-        });
-      }
-      return accountSuggestions;
-    });
-  }
-
-  _getGroupSuggestions(input) {
-    return this.$.restAPI.getSuggestedGroups(input)
-        .then(response => {
-          const groups = [];
-          for (const key in response) {
-            if (!response.hasOwnProperty(key)) { continue; }
-            groups.push({
-              name: key,
-              value: decodeURIComponent(response[key].id),
-            });
-          }
-          return groups;
-        });
-  }
-
-  _computeHideItemClass(owner, admin) {
-    return admin || owner ? '' : 'canModify';
-  }
-}
-
-customElements.define(GrGroupMembers.is, GrGroupMembers);
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
new file mode 100644
index 0000000..f8f1fee
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -0,0 +1,396 @@
+/**
+ * @license
+ * Copyright (C) 2017 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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
+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-group-members_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+  RestApiService,
+  ErrorCallback,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {
+  GroupId,
+  AccountId,
+  AccountInfo,
+  GroupInfo,
+} from '../../../types/common';
+import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const SUGGESTIONS_LIMIT = 15;
+const SAVING_ERROR_TEXT =
+  'Group may not exist, or you may not have ' + 'permission to add it';
+
+const URL_REGEX = '^(?:[a-z]+:)?//';
+
+export interface GrGroupMembers {
+  $: {
+    restAPI: RestApiService & Element;
+    overlay: GrOverlay;
+  };
+}
+@customElement('gr-group-members')
+export class GrGroupMembers extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Number})
+  groupId?: GroupId;
+
+  @property({type: Number})
+  _groupMemberSearchId?: number;
+
+  @property({type: String})
+  _groupMemberSearchName?: string;
+
+  @property({type: String})
+  _includedGroupSearchId?: string;
+
+  @property({type: String})
+  _includedGroupSearchName?: string;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _groupName?: GroupId;
+
+  @property({type: Object})
+  _groupMembers?: AccountInfo[];
+
+  @property({type: Object})
+  _includedGroups?: GroupInfo[];
+
+  @property({type: String})
+  _itemName?: GroupInfo | AccountInfo;
+
+  @property({type: String})
+  _itemType?: string;
+
+  @property({type: Object})
+  _queryMembers: AutocompleteQuery;
+
+  @property({type: Object})
+  _queryIncludedGroup: AutocompleteQuery;
+
+  @property({type: Boolean})
+  _groupOwner = false;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  _itemId?: AccountId | GroupId;
+
+  constructor() {
+    super();
+    this._queryMembers = input => this._getAccountSuggestions(input);
+    this._queryIncludedGroup = input => this._getGroupSuggestions(input);
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadGroupDetails();
+
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: 'Members'},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _loadGroupDetails() {
+    if (!this.groupId) {
+      return;
+    }
+
+    const promises: Promise<void>[] = [];
+
+    const errFn: ErrorCallback = response => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+
+    return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
+      if (!config || !config.name) {
+        return Promise.resolve();
+      }
+
+      this._groupName = config.name as GroupId;
+
+      promises.push(
+        this.$.restAPI.getIsAdmin().then(isAdmin => {
+          this._isAdmin = !!isAdmin;
+        })
+      );
+
+      promises.push(
+        this.$.restAPI.getIsGroupOwner(this._groupName).then(isOwner => {
+          this._groupOwner = !!isOwner;
+        })
+      );
+
+      promises.push(
+        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+          this._groupMembers = members;
+        })
+      );
+
+      promises.push(
+        this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
+          this._includedGroups = includedGroup;
+        })
+      );
+
+      return Promise.all(promises).then(() => {
+        this._loading = false;
+      });
+    });
+  }
+
+  _computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _computeGroupUrl(url: string) {
+    if (!url) {
+      return;
+    }
+
+    const r = new RegExp(URL_REGEX, 'i');
+    if (r.test(url)) {
+      return url;
+    }
+
+    // For GWT compatibility
+    if (url.startsWith('#')) {
+      return getBaseUrl() + url.slice(1);
+    }
+    return getBaseUrl() + url;
+  }
+
+  _handleSavingGroupMember() {
+    if (!this._groupName) {
+      return Promise.reject(new Error('group name undefined'));
+    }
+    return this.$.restAPI
+      .saveGroupMember(this._groupName, this._groupMemberSearchId as AccountId)
+      .then(config => {
+        if (!config || !this._groupName) {
+          return;
+        }
+        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+          this._groupMembers = members;
+        });
+        this._groupMemberSearchName = '';
+        this._groupMemberSearchId = undefined;
+      });
+  }
+
+  _handleDeleteConfirm() {
+    if (!this._groupName) {
+      return Promise.reject(new Error('group name undefined'));
+    }
+    this.$.overlay.close();
+    if (this._itemType === 'member') {
+      return this.$.restAPI
+        .deleteGroupMember(this._groupName, this._itemId! as AccountId)
+        .then(itemDeleted => {
+          if (itemDeleted.status === 204 && this._groupName) {
+            this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+              this._groupMembers = members;
+            });
+          }
+        });
+    } else if (this._itemType === 'includedGroup') {
+      return this.$.restAPI
+        .deleteIncludedGroup(this._groupName, this._itemId! as GroupId)
+        .then(itemDeleted => {
+          if (
+            (itemDeleted.status === 204 || itemDeleted.status === 205) &&
+            this._groupName
+          ) {
+            this.$.restAPI
+              .getIncludedGroup(this._groupName)
+              .then(includedGroup => {
+                this._includedGroups = includedGroup;
+              });
+          }
+        });
+    }
+    return Promise.reject(new Error('Unrecognized item type'));
+  }
+
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteMember(e: PolymerDomRepeatEvent<AccountInfo>) {
+    const id = (e.model.get('item._account_id') as unknown) as AccountId;
+    const name = e.model.get('item.name');
+    const username = e.model.get('item.username');
+    const email = e.model.get('item.email');
+    const item = username || name || email || id;
+    if (!item) {
+      return;
+    }
+    this._itemName = item;
+    this._itemId = id;
+    this._itemType = 'member';
+    this.$.overlay.open();
+  }
+
+  _handleSavingIncludedGroups() {
+    if (!this._groupName || !this._includedGroupSearchId) {
+      return Promise.reject(
+        new Error('group name or includedGroupSearchId undefined')
+      );
+    }
+    return this.$.restAPI
+      .saveIncludedGroup(
+        this._groupName,
+        this._includedGroupSearchId.replace(/\+/g, ' ') as GroupId,
+        (errResponse, err) => {
+          if (errResponse) {
+            if (errResponse.status === 404) {
+              this.dispatchEvent(
+                new CustomEvent('show-alert', {
+                  detail: {message: SAVING_ERROR_TEXT},
+                  bubbles: true,
+                  composed: true,
+                })
+              );
+              return errResponse;
+            }
+            throw Error(errResponse.statusText);
+          }
+          throw err;
+        }
+      )
+      .then(config => {
+        if (!config || !this._groupName) {
+          return;
+        }
+        this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
+          this._includedGroups = includedGroup;
+        });
+        this._includedGroupSearchName = '';
+        this._includedGroupSearchId = '';
+      });
+  }
+
+  _handleDeleteIncludedGroup(e: PolymerDomRepeatEvent<GroupInfo>) {
+    const id = decodeURIComponent(`${e.model.get('item.id')}`).replace(
+      /\+/g,
+      ' '
+    ) as GroupId;
+    const name = e.model.get('item.name');
+    const item = name || id;
+    if (!item) {
+      return;
+    }
+    this._itemName = item;
+    this._itemId = id;
+    this._itemType = 'includedGroup';
+    this.$.overlay.open();
+  }
+
+  _getAccountSuggestions(input: string) {
+    if (input.length === 0) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI
+      .getSuggestedAccounts(input, SUGGESTIONS_LIMIT)
+      .then(accounts => {
+        const accountSuggestions = [];
+        let nameAndEmail;
+        if (!accounts) {
+          return [];
+        }
+        for (const key in accounts) {
+          if (!hasOwnProperty(accounts, key)) {
+            continue;
+          }
+          if (accounts[key].email !== undefined) {
+            nameAndEmail = `${accounts[key].name} <${accounts[key].email}>`;
+          } else {
+            nameAndEmail = accounts[key].name;
+          }
+          accountSuggestions.push({
+            name: nameAndEmail,
+            value: accounts[key]._account_id,
+          });
+        }
+        return accountSuggestions;
+      });
+  }
+
+  _getGroupSuggestions(input: string) {
+    return this.$.restAPI.getSuggestedGroups(input).then(response => {
+      const groups = [];
+      for (const key in response) {
+        if (!hasOwnProperty(response, key)) {
+          continue;
+        }
+        groups.push({
+          name: key,
+          value: decodeURIComponent(response[key].id),
+        });
+      }
+      return groups;
+    });
+  }
+
+  _computeHideItemClass(owner: boolean, admin: boolean) {
+    return admin || owner ? '' : 'canModify';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-group-members': GrGroupMembers;
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
index 03fccde..b5d2217 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -17,7 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-members.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group-members');
@@ -210,6 +210,7 @@
 
   test('add included group 404 shows helpful error text', () => {
     element._groupOwner = true;
+    element._groupName = 'test';
 
     const memberName = 'bad-name';
     const alertStub = sinon.stub();
@@ -224,9 +225,9 @@
     element.$.groupMemberSearchInput.text = memberName;
     element.$.groupMemberSearchInput.value = 1234;
 
-    return element._handleSavingIncludedGroups().then(() => {
+    return flush(element._handleSavingIncludedGroups().then(() => {
       assert.isTrue(alertStub.called);
-    });
+    }));
   });
 
   test('add included group network-error throws an exception', async () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
deleted file mode 100644
index 673332b..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ /dev/null
@@ -1,321 +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 '../../../styles/gr-change-list-styles.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-change-star/gr-change-star.js';
-import '../../shared/gr-change-status/gr-change-status.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-limited-text/gr-limited-text.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../../styles/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.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-change-list-item_html.js';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getDisplayName} from '../../../utils/display-name-util.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {truncatePath} from '../../../utils/path-list-util.js';
-import {changeStatuses} from '../../../utils/change-util.js';
-import {isServiceUser} from '../../../utils/account-util.js';
-
-const CHANGE_SIZE = {
-  XS: 10,
-  SMALL: 50,
-  MEDIUM: 250,
-  LARGE: 1000,
-};
-
-// How many reviewers should be shown with an account-label?
-const PRIMARY_REVIEWERS_COUNT = 2;
-
-/**
- * @extends PolymerElement
- */
-class GrChangeListItem extends ChangeTableMixin(GestureEventListeners(
-    LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-list-item'; }
-
-  static get properties() {
-    return {
-      /** The logged-in user's account, or null if no user is logged in. */
-      account: {
-        type: Object,
-        value: null,
-      },
-      visibleChangeTableColumns: Array,
-      labelNames: {
-        type: Array,
-      },
-
-      /** @type {?} */
-      change: Object,
-      config: Object,
-      /** Name of the section in the change-list. Used for reporting. */
-      sectionName: String,
-      changeURL: {
-        type: String,
-        computed: '_computeChangeURL(change)',
-      },
-      statuses: {
-        type: Array,
-        computed: '_changeStatuses(change)',
-      },
-      showStar: {
-        type: Boolean,
-        value: false,
-      },
-      showNumber: Boolean,
-      _changeSize: {
-        type: String,
-        computed: '_computeChangeSize(change)',
-      },
-      _dynamicCellEndpoints: {
-        type: Array,
-      },
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          this._dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
-              'change-list-item-cell');
-        });
-  }
-
-  _changeStatuses(change) {
-    return changeStatuses(change);
-  }
-
-  _computeChangeURL(change) {
-    return GerritNav.getUrlForChange(change);
-  }
-
-  _computeLabelTitle(change, labelName) {
-    const label = change.labels[labelName];
-    if (!label) { return 'Label not applicable'; }
-    const significantLabel = label.rejected || label.approved ||
-        label.disliked || label.recommended;
-    if (significantLabel && significantLabel.name) {
-      return labelName + '\nby ' + significantLabel.name;
-    }
-    return labelName;
-  }
-
-  _computeLabelClass(change, labelName) {
-    const label = change.labels[labelName];
-    // Mimic a Set.
-    const classes = {
-      cell: true,
-      label: true,
-    };
-    if (label) {
-      if (label.approved) {
-        classes['u-green'] = true;
-      }
-      if (label.value == 1) {
-        classes['u-monospace'] = true;
-        classes['u-green'] = true;
-      } else if (label.value == -1) {
-        classes['u-monospace'] = true;
-        classes['u-red'] = true;
-      }
-      if (label.rejected) {
-        classes['u-red'] = true;
-      }
-    } else {
-      classes['u-gray-background'] = true;
-    }
-    return Object.keys(classes).sort()
-        .join(' ');
-  }
-
-  _computeLabelValue(change, labelName) {
-    const label = change.labels[labelName];
-    if (!label) { return ''; }
-    if (label.approved) {
-      return '✓';
-    }
-    if (label.rejected) {
-      return '✕';
-    }
-    if (label.value > 0) {
-      return '+' + label.value;
-    }
-    if (label.value < 0) {
-      return label.value;
-    }
-    return '';
-  }
-
-  _computeRepoUrl(change) {
-    return GerritNav.getUrlForProjectChanges(change.project, true,
-        change.internalHost);
-  }
-
-  _computeRepoBranchURL(change) {
-    return GerritNav.getUrlForBranch(change.branch, change.project, null,
-        change.internalHost);
-  }
-
-  _computeTopicURL(change) {
-    if (!change.topic) { return ''; }
-    return GerritNav.getUrlForTopic(change.topic, change.internalHost);
-  }
-
-  /**
-   * Computes the display string for the project column. If there is a host
-   * specified in the change detail, the string will be prefixed with it.
-   *
-   * @param {!Object} change
-   * @param {string=} truncate whether or not the project name should be
-   *     truncated. If this value is truthy, the name will be truncated.
-   * @return {string}
-   */
-  _computeRepoDisplay(change, truncate) {
-    if (!change || !change.project) { return ''; }
-    let str = '';
-    if (change.internalHost) { str += change.internalHost + '/'; }
-    str += truncate ? truncatePath(change.project, 2) : change.project;
-    return str;
-  }
-
-  _computeSizeTooltip(change) {
-    if (change.insertions + change.deletions === 0 ||
-        isNaN(change.insertions + change.deletions)) {
-      return 'Size unknown';
-    } else {
-      return `added ${change.insertions}, removed ${change.deletions} lines`;
-    }
-  }
-
-  _hasAttention(account) {
-    if (!this.change || !this.change.attention_set) return false;
-    return this.change.attention_set.hasOwnProperty(account._account_id);
-  }
-
-  /**
-   * Computes the array of all reviewers with sorting the reviewers in the
-   * attention set before others, and the current user first.
-   */
-  _computeReviewers(change) {
-    if (!change || !change.reviewers || !change.reviewers.REVIEWER) return [];
-    const reviewers = [...change.reviewers.REVIEWER].filter(r =>
-      (!change.owner || change.owner._account_id !== r._account_id) &&
-      !isServiceUser(r)
-    );
-    reviewers.sort((r1, r2) => {
-      if (this.account) {
-        if (r1._account_id === this.account._account_id) return -1;
-        if (r2._account_id === this.account._account_id) return 1;
-      }
-      if (this._hasAttention(r1) && !this._hasAttention(r2)) return -1;
-      if (this._hasAttention(r2) && !this._hasAttention(r1)) return 1;
-      return (r1.name || '').localeCompare(r2.name || '');
-    });
-    return reviewers;
-  }
-
-  _computePrimaryReviewers(change) {
-    return this._computeReviewers(change).slice(0, PRIMARY_REVIEWERS_COUNT);
-  }
-
-  _computeAdditionalReviewers(change) {
-    return this._computeReviewers(change).slice(PRIMARY_REVIEWERS_COUNT);
-  }
-
-  _computeAdditionalReviewersCount(change) {
-    return this._computeAdditionalReviewers(change).length;
-  }
-
-  _computeAdditionalReviewersTitle(change, config) {
-    if (!change || !config) return '';
-    return this._computeAdditionalReviewers(change)
-        .map(user => getDisplayName(config, user, true))
-        .join(', ');
-  }
-
-  _computeComments(unresolved_comment_count) {
-    if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
-    return `${unresolved_comment_count} unresolved`;
-  }
-
-  /**
-   * TShirt sizing is based on the following paper:
-   * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
-   */
-  _computeChangeSize(change) {
-    const delta = change.insertions + change.deletions;
-    if (isNaN(delta) || delta === 0) {
-      return null; // Unknown
-    }
-    if (delta < CHANGE_SIZE.XS) {
-      return 'XS';
-    } else if (delta < CHANGE_SIZE.SMALL) {
-      return 'S';
-    } else if (delta < CHANGE_SIZE.MEDIUM) {
-      return 'M';
-    } else if (delta < CHANGE_SIZE.LARGE) {
-      return 'L';
-    } else {
-      return 'XL';
-    }
-  }
-
-  toggleReviewed() {
-    const newVal = !this.change.reviewed;
-    this.set('change.reviewed', newVal);
-    this.dispatchEvent(new CustomEvent('toggle-reviewed', {
-      bubbles: true,
-      composed: true,
-      detail: {change: this.change, reviewed: newVal},
-    }));
-  }
-
-  _handleChangeClick(e) {
-    // Don't prevent the default and neither stop bubbling. We just want to
-    // report the click, but then let the browser handle the click on the link.
-
-    const selfId = (this.account && this.account._account_id) || -1;
-    const ownerId = (this.change && this.change.owner
-        && this.change.owner._account_id) || -1;
-
-    this.reporting.reportInteraction('change-row-clicked', {
-      section: this.sectionName,
-      isOwner: selfId === ownerId,
-    });
-  }
-}
-
-customElements.define(GrChangeListItem.is, GrChangeListItem);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
new file mode 100644
index 0000000..5d898bd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -0,0 +1,366 @@
+/**
+ * @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 '../../../styles/gr-change-list-styles';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-change-star/gr-change-star';
+import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-limited-text/gr-limited-text';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+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-change-list-item_html';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {truncatePath} from '../../../utils/path-list-util';
+import {changeStatuses} from '../../../utils/change-util';
+import {isServiceUser} from '../../../utils/account-util';
+import {customElement, property} from '@polymer/decorators';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {
+  ChangeInfo,
+  ServerInfo,
+  AccountInfo,
+  QuickLabelInfo,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+enum CHANGE_SIZE {
+  XS = 10,
+  SMALL = 50,
+  MEDIUM = 250,
+  LARGE = 1000,
+}
+
+// How many reviewers should be shown with an account-label?
+const PRIMARY_REVIEWERS_COUNT = 2;
+
+@customElement('gr-change-list-item')
+class GrChangeListItem extends ChangeTableMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /** The logged-in user's account, or null if no user is logged in. */
+  @property({type: Object})
+  account: AccountInfo | null = null;
+
+  @property({type: Array})
+  visibleChangeTableColumns?: string[];
+
+  @property({type: Array})
+  labelNames?: string[];
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property({type: Object})
+  config?: ServerInfo;
+
+  /** Name of the section in the change-list. Used for reporting. */
+  @property({type: String})
+  sectionName?: string;
+
+  @property({type: String, computed: '_computeChangeURL(change)'})
+  changeURL?: string;
+
+  @property({type: Array, computed: '_changeStatuses(change)'})
+  statuses?: string[];
+
+  @property({type: Boolean})
+  showStar = false;
+
+  @property({type: Boolean})
+  showNumber = false;
+
+  @property({type: String, computed: '_computeChangeSize(change)'})
+  _changeSize?: string;
+
+  @property({type: Array})
+  _dynamicCellEndpoints?: string[];
+
+  reporting: ReportingService = appContext.reportingService;
+
+  /** @override */
+  attached() {
+    super.attached();
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-list-item-cell'
+        );
+      });
+  }
+
+  _changeStatuses(change?: ChangeInfo) {
+    if (!change) return [];
+    return changeStatuses(change);
+  }
+
+  _computeChangeURL(change?: ChangeInfo) {
+    if (!change) return '';
+    return GerritNav.getUrlForChange(change);
+  }
+
+  _computeLabelTitle(change: ChangeInfo | undefined, labelName: string) {
+    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+    if (!label) {
+      return 'Label not applicable';
+    }
+    const significantLabel =
+      label.rejected || label.approved || label.disliked || label.recommended;
+    if (significantLabel && significantLabel.name) {
+      return `${labelName}\nby ${significantLabel.name}`;
+    }
+    return labelName;
+  }
+
+  _computeLabelClass(change: ChangeInfo | undefined, labelName: string) {
+    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+    // Mimic a Set.
+    // TODO(TS): replace with `u_green` to remove the quotes and brackets
+    const classes: {
+      cell: boolean;
+      label: boolean;
+      ['u-green']?: boolean;
+      ['u-monospace']?: boolean;
+      ['u-red']?: boolean;
+      ['u-gray-background']?: boolean;
+    } = {
+      cell: true,
+      label: true,
+    };
+    if (label) {
+      if (label.approved) {
+        classes['u-green'] = true;
+      }
+      if (label.value === 1) {
+        classes['u-monospace'] = true;
+        classes['u-green'] = true;
+      } else if (label.value === -1) {
+        classes['u-monospace'] = true;
+        classes['u-red'] = true;
+      }
+      if (label.rejected) {
+        classes['u-red'] = true;
+      }
+    } else {
+      classes['u-gray-background'] = true;
+    }
+    return Object.keys(classes).sort().join(' ');
+  }
+
+  _computeLabelValue(change: ChangeInfo | undefined, labelName: string) {
+    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+    if (!label) {
+      return '';
+    }
+    if (label.approved) {
+      return '✓';
+    }
+    if (label.rejected) {
+      return '✕';
+    }
+    if (label.value && label.value > 0) {
+      return `+${label.value}`;
+    }
+    if (label.value && label.value < 0) {
+      return label.value;
+    }
+    return '';
+  }
+
+  _computeRepoUrl(change?: ChangeInfo) {
+    if (!change) return '';
+    return GerritNav.getUrlForProjectChanges(
+      change.project,
+      true,
+      change.internalHost
+    );
+  }
+
+  _computeRepoBranchURL(change?: ChangeInfo) {
+    if (!change) return '';
+    return GerritNav.getUrlForBranch(
+      change.branch,
+      change.project,
+      undefined,
+      change.internalHost
+    );
+  }
+
+  _computeTopicURL(change?: ChangeInfo) {
+    if (!change?.topic) {
+      return '';
+    }
+    return GerritNav.getUrlForTopic(change.topic, change.internalHost);
+  }
+
+  /**
+   * Computes the display string for the project column. If there is a host
+   * specified in the change detail, the string will be prefixed with it.
+   *
+   * @param truncate whether or not the project name should be
+   * truncated. If this value is truthy, the name will be truncated.
+   */
+  _computeRepoDisplay(change: ChangeInfo | undefined, truncate: boolean) {
+    if (!change?.project) {
+      return '';
+    }
+    let str = '';
+    if (change.internalHost) {
+      str += change.internalHost + '/';
+    }
+    str += truncate ? truncatePath(change.project, 2) : change.project;
+    return str;
+  }
+
+  _computeSizeTooltip(change?: ChangeInfo) {
+    if (
+      !change ||
+      change.insertions + change.deletions === 0 ||
+      isNaN(change.insertions + change.deletions)
+    ) {
+      return 'Size unknown';
+    } else {
+      return `added ${change.insertions}, removed ${change.deletions} lines`;
+    }
+  }
+
+  _hasAttention(account: AccountInfo) {
+    if (!this.change || !this.change.attention_set) return false;
+    return hasOwnProperty(this.change.attention_set, account._account_id);
+  }
+
+  /**
+   * Computes the array of all reviewers with sorting the reviewers in the
+   * attention set before others, and the current user first.
+   */
+  _computeReviewers(change?: ChangeInfo) {
+    if (!change?.reviewers || !change?.reviewers.REVIEWER) return [];
+    const reviewers = [...change.reviewers.REVIEWER].filter(
+      r =>
+        (!change.owner || change.owner._account_id !== r._account_id) &&
+        !isServiceUser(r)
+    );
+    reviewers.sort((r1, r2) => {
+      if (this.account) {
+        if (r1._account_id === this.account._account_id) return -1;
+        if (r2._account_id === this.account._account_id) return 1;
+      }
+      if (this._hasAttention(r1) && !this._hasAttention(r2)) return -1;
+      if (this._hasAttention(r2) && !this._hasAttention(r1)) return 1;
+      return (r1.name || '').localeCompare(r2.name || '');
+    });
+    return reviewers;
+  }
+
+  _computePrimaryReviewers(change?: ChangeInfo) {
+    return this._computeReviewers(change).slice(0, PRIMARY_REVIEWERS_COUNT);
+  }
+
+  _computeAdditionalReviewers(change?: ChangeInfo) {
+    return this._computeReviewers(change).slice(PRIMARY_REVIEWERS_COUNT);
+  }
+
+  _computeAdditionalReviewersCount(change?: ChangeInfo) {
+    return this._computeAdditionalReviewers(change).length;
+  }
+
+  _computeAdditionalReviewersTitle(
+    change: ChangeInfo | undefined,
+    config: ServerInfo
+  ) {
+    if (!change || !config) return '';
+    return this._computeAdditionalReviewers(change)
+      .map(user => getDisplayName(config, user, true))
+      .join(', ');
+  }
+
+  _computeComments(unresolved_comment_count?: number) {
+    if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
+    return `${unresolved_comment_count} unresolved`;
+  }
+
+  /**
+   * TShirt sizing is based on the following paper:
+   * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
+   */
+  _computeChangeSize(change?: ChangeInfo) {
+    if (!change) return null;
+    const delta = change.insertions + change.deletions;
+    if (isNaN(delta) || delta === 0) {
+      return null; // Unknown
+    }
+    if (delta < CHANGE_SIZE.XS) {
+      return 'XS';
+    } else if (delta < CHANGE_SIZE.SMALL) {
+      return 'S';
+    } else if (delta < CHANGE_SIZE.MEDIUM) {
+      return 'M';
+    } else if (delta < CHANGE_SIZE.LARGE) {
+      return 'L';
+    } else {
+      return 'XL';
+    }
+  }
+
+  toggleReviewed() {
+    const newVal = !this.change?.reviewed;
+    this.set('change.reviewed', newVal);
+    this.dispatchEvent(
+      new CustomEvent('toggle-reviewed', {
+        bubbles: true,
+        composed: true,
+        detail: {change: this.change, reviewed: newVal},
+      })
+    );
+  }
+
+  _handleChangeClick() {
+    // Don't prevent the default and neither stop bubbling. We just want to
+    // report the click, but then let the browser handle the click on the link.
+
+    const selfId = (this.account && this.account._account_id) || -1;
+    const ownerId =
+      (this.change && this.change.owner && this.change.owner._account_id) || -1;
+
+    this.reporting.reportInteraction('change-row-clicked', {
+      section: this.sectionName,
+      isOwner: selfId === ownerId,
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-item': GrChangeListItem;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
index 6d51310..1970928 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
@@ -278,7 +278,7 @@
     assert.deepEqual(GerritNav.getUrlForProjectChanges.lastCall.args,
         [change.project, true, change.internalHost]);
     assert.deepEqual(GerritNav.getUrlForBranch.lastCall.args,
-        [change.branch, change.project, null, change.internalHost]);
+        [change.branch, change.project, undefined, change.internalHost]);
     assert.deepEqual(GerritNav.getUrlForTopic.lastCall.args,
         [change.topic, change.internalHost]);
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index affa8b5..c4ebe1e 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -24,6 +24,10 @@
 import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
 import {property, observe} from '@polymer/decorators';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {
+  pushScrollLock,
+  removeScrollLock,
+} from '@polymer/iron-overlay-behavior/iron-scroll-manager';
 
 const HOVER_CLASS = 'hovered';
 const HIDE_CLASS = 'hide';
@@ -127,8 +131,10 @@
         // show the hovercard if mouse moves to hovercard
         // this will cancel pending hide as well
         this.listen(this, 'mouseenter', 'show');
+        this.listen(this, 'mouseenter', 'lock');
         // when leave hovercard, hide it immediately
         this.listen(this, 'mouseleave', 'hide');
+        this.listen(this, 'mouseleave', 'unlock');
       }
 
       /** @override */
@@ -213,6 +219,13 @@
       }
 
       /**
+       * unlock scroll, this will resume the scroll outside of the hovercard.
+       */
+      unlock() {
+        removeScrollLock(this);
+      }
+
+      /**
        * Hides/closes the hovercard. This occurs when the user triggers the
        * `mouseleave` event on the hovercard's `target` element (as long as the
        * user is not hovering over the hovercard).
@@ -287,6 +300,13 @@
       }
 
       /**
+       * Lock background scroll but enable scroll inside of current hovercard.
+       */
+      lock() {
+        pushScrollLock(this);
+      }
+
+      /**
        * Shows/opens the hovercard. This occurs when the user triggers the
        * `mousenter` event on the hovercard's `target` element.
        */
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 e3c2ce8..d172f2a 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
@@ -75,6 +75,8 @@
   ProjectInput,
   AccountId,
   ChangeMessageId,
+  GroupAuditEventInfo,
+  EncodedGroupId,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {HttpMethod} from '../../../constants/constants';
@@ -605,8 +607,42 @@
     changeNum: ChangeNum,
     messageId: ChangeMessageId
   ): Promise<Response>;
+
   removeChangeReviewer(
     changeNum: ChangeNum,
     reviewerID: AccountId | GroupId
   ): Promise<Response | undefined>;
+
+  getGroupAuditLog(
+    group: EncodedGroupId,
+    errFn?: ErrorCallback
+  ): Promise<GroupAuditEventInfo[] | undefined>;
+
+  getGroupMembers(
+    groupName: GroupId,
+    errFn?: ErrorCallback
+  ): Promise<AccountInfo[] | undefined>;
+
+  getIncludedGroup(groupName: GroupId): Promise<GroupInfo[] | undefined>;
+
+  saveGroupMember(
+    groupName: GroupId,
+    groupMember: AccountId
+  ): Promise<AccountInfo>;
+
+  saveIncludedGroup(
+    groupName: GroupId,
+    includedGroup: GroupId,
+    errFn?: ErrorCallback
+  ): Promise<GroupInfo | undefined>;
+
+  deleteGroupMember(
+    groupName: GroupId,
+    groupMember: AccountId
+  ): Promise<Response>;
+
+  deleteIncludedGroup(
+    groupName: GroupId,
+    includedGroup: GroupId
+  ): Promise<Response>;
 }
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 8dd9371..970fa21 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -126,6 +126,8 @@
    * The index of the element in the dom-repeat.
    */
   index: number;
+  get: (name: string) => T;
+  set: (name: string, val: T) => void;
 }
 
 /** https://highlightjs.readthedocs.io/en/latest/api.html */
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 298ae9d..de43884c 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -16,16 +16,7 @@
  */
 import {getBaseUrl} from './url-util';
 import {ChangeStatus} from '../constants/constants';
-import {LegacyChangeId, PatchSetNum} from '../types/common';
-
-// This can be wrong! See WARNING above
-interface Change {
-  status: string; // This can be wrong! See WARNING above
-  mergeable: boolean; // This can be wrong! See WARNING above
-  work_in_progress: boolean; // This can be wrong! See WARNING above
-  is_private: boolean; // This can be wrong! See WARNING above
-  submittable: boolean; // This can be wrong! See WARNING above
-}
+import {LegacyChangeId, PatchSetNum, ChangeInfo} from '../types/common';
 
 // This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
@@ -132,12 +123,12 @@
   return `${getBaseUrl()}/c/${changeNum}`;
 }
 
-export function changeIsOpen(change?: Change) {
+export function changeIsOpen(change?: ChangeInfo) {
   return change?.status === ChangeStatus.NEW;
 }
 
 export function changeStatuses(
-  change: Change,
+  change: ChangeInfo,
   opt_options?: ChangeStatusesOptions
 ) {
   const states = [];
@@ -175,6 +166,6 @@
   return states;
 }
 
-export function changeStatusString(change: Change) {
+export function changeStatusString(change: ChangeInfo) {
   return changeStatuses(change).join(', ');
 }