Merge "Add a plugin endpoint to the cherrypick dialog"
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 20ad07c..24c35f0 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -18,8 +18,8 @@
 [[user]]
 == User Level Settings
 
-Individual users can configure email subscriptions by editing
-watched projects through Settings > Watched Projects with the web UI.
+Individual users can configure email subscriptions by editing their
+notifications in the Web UI under `Settings` > `Notifications`.
 
 Specific projects may be watched, or the special project
 `All-Projects` can be watched to watch all projects that
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index 629cf83..2fba6ab 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
@@ -43,7 +44,7 @@
               GroupField.NAME_PART_FIELD,
               GroupField.OWNER_UUID_FIELD,
               GroupField.SUBGROUP_FIELD),
-          ImmutableList.of(
+          ImmutableList.<IndexedField<InternalGroup, ?>.SearchSpec>of(
               GroupField.CREATED_ON_SPEC,
               GroupField.DESCRIPTION_SPEC,
               GroupField.ID_FIELD_SPEC,
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
index 5a4a90c..ef48323 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -891,8 +891,6 @@
       );
 
       assert.deepEqual(reviewerList.accounts[1], {
-        _group: true,
-        _pendingAdd: true,
         confirmed: true,
         id: '5' as GroupId,
         name: 'large-group',
@@ -933,8 +931,6 @@
         getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
       );
       assert.deepEqual(reviewerList.accounts[1], {
-        _group: true,
-        _pendingAdd: true,
         id: '5' as GroupId,
         name: 'small-group',
       });
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 753097a..ccdcc15 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -27,15 +27,14 @@
 } from '../../../constants/constants';
 import {
   accountOrGroupKey,
-  isReviewerOrCC,
-  mapReviewer,
+  isAccountNewlyAdded,
   removeServiceUsers,
+  toReviewInput,
 } from '../../../utils/account-util';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {TargetElement} from '../../../api/plugin';
 import {FixIronA11yAnnouncer} from '../../../types/types';
 import {
-  AccountAddition,
   AccountInfoInput,
   AccountInput,
   AccountInputDetail,
@@ -71,6 +70,7 @@
   areSetsEqual,
   assertIsDefined,
   containsAll,
+  difference,
   queryAndAssert,
 } from '../../../utils/common-util';
 import {CommentThread, isUnresolved} from '../../../utils/comment-util';
@@ -108,6 +108,7 @@
 import {customElement, property, state, query} from 'lit/decorators';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
+import {hasHumanReviewer, isOwner} from '../../../utils/change-util';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -745,6 +746,7 @@
           id="reviewers"
           .accounts=${this.getAccountListCopy(this.reviewers)}
           .change=${this.change}
+          .reviewerState=${ReviewerState.REVIEWER}
           @account-added=${this.accountAdded}
           @accounts-changed=${this.handleReviewersChanged}
           .removableValues=${this.change?.removable_reviewers}
@@ -771,6 +773,8 @@
         <gr-account-list
           id="ccs"
           .accounts=${this.getAccountListCopy(this.ccs)}
+          .change=${this.change}
+          .reviewerState=${ReviewerState.CC}
           @account-added=${this.accountAdded}
           @accounts-changed=${this.handleCcsChanged}
           .removableValues=${this.change?.removable_reviewers}
@@ -1281,42 +1285,32 @@
     return isResolvedPatchsetLevelComment ? 'resolved' : 'unresolved';
   }
 
-  computeReviewers(change: ChangeInfo) {
+  computeReviewers() {
     const reviewers: ReviewerInput[] = [];
-    const addToReviewInput = (
-      additions: AccountAddition[],
-      state?: ReviewerState
-    ) => {
-      additions.forEach(addition => {
-        const reviewer = mapReviewer(addition);
-        if (state) reviewer.state = state;
-        reviewers.push(reviewer);
-      });
-    };
-    addToReviewInput(this.reviewersList!.additions(), ReviewerState.REVIEWER);
-    addToReviewInput(this.ccsList!.additions(), ReviewerState.CC);
-    addToReviewInput(
-      this.reviewersList!.removals().filter(
-        r =>
-          isReviewerOrCC(change, r) &&
-          // ignore removal from reviewer request if being added to CC
-          !this.ccsList!.additions().some(
-            account => mapReviewer(account).reviewer === mapReviewer(r).reviewer
-          )
-      ),
-      ReviewerState.REMOVED
+    const reviewerAdditions = this.reviewersList?.additions() ?? [];
+    reviewers.push(
+      ...reviewerAdditions.map(v => toReviewInput(v, ReviewerState.REVIEWER))
     );
-    addToReviewInput(
-      this.ccsList!.removals().filter(
-        r =>
-          isReviewerOrCC(change, r) &&
-          // ignore removal from CC request if being added as reviewer
-          !this.reviewersList!.additions().some(
-            account => mapReviewer(account).reviewer === mapReviewer(r).reviewer
-          )
-      ),
-      ReviewerState.REMOVED
-    );
+
+    const ccAdditions = this.ccsList?.additions() ?? [];
+    reviewers.push(...ccAdditions.map(v => toReviewInput(v, ReviewerState.CC)));
+
+    // ignore removal from reviewer request if being added as CC
+    let removals = difference(
+      this.reviewersList?.removals() ?? [],
+      ccAdditions,
+      (a, b) => accountOrGroupKey(a) === accountOrGroupKey(b)
+    ).map(v => toReviewInput(v, ReviewerState.REMOVED));
+    reviewers.push(...removals);
+
+    // ignore removal from CC request if being added as reviewer
+    removals = difference(
+      this.ccsList?.removals() ?? [],
+      reviewerAdditions,
+      (a, b) => accountOrGroupKey(a) === accountOrGroupKey(b)
+    ).map(v => toReviewInput(v, ReviewerState.REMOVED));
+    reviewers.push(...removals);
+
     return reviewers;
   }
 
@@ -1367,7 +1361,7 @@
     }
 
     assertIsDefined(this.change, 'change');
-    reviewInput.reviewers = this.computeReviewers(this.change);
+    reviewInput.reviewers = this.computeReviewers();
     this.disabled = true;
 
     const errFn = (r?: Response | null) => this.handle400Error(r);
@@ -1422,19 +1416,9 @@
   }
 
   chooseFocusTarget() {
-    // If we are the owner and the reviewers field is empty, focus on that.
-    if (
-      this.account &&
-      this.change &&
-      this.change.owner &&
-      this.account._account_id === this.change.owner._account_id &&
-      (!this.reviewers || this.reviewers?.length === 0)
-    ) {
-      return FocusTarget.REVIEWERS;
-    }
-
-    // Default to BODY.
-    return FocusTarget.BODY;
+    if (!isOwner(this.change, this.account)) return FocusTarget.BODY;
+    if (hasHumanReviewer(this.change)) return FocusTarget.BODY;
+    return FocusTarget.REVIEWERS;
   }
 
   isOwner(account?: AccountInfo, change?: ChangeInfo) {
@@ -1624,7 +1608,11 @@
         );
       this.reviewers
         .filter(r => isAccount(r))
-        .filter(r => r._pendingAdd || (this.canBeStarted && isOwner))
+        .filter(
+          r =>
+            isAccountNewlyAdded(r, ReviewerState.REVIEWER, this.change) ||
+            (this.canBeStarted && isOwner)
+        )
         .filter(notIsReviewerAndHasDraftOrLabel)
         .forEach(r => newAttention.add((r as AccountInfo)._account_id!));
       // Add owner and uploader, if someone else replies.
@@ -1812,7 +1800,6 @@
       })
     );
     queryAndAssert<GrTextarea>(this, 'gr-textarea').closeDropdown();
-    this.reviewersList?.clearPendingRemovals();
     this.rebuildReviewerArrays();
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 55a98f2..67190de 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -28,6 +28,7 @@
   createCommentThread,
   createDraft,
   createRevision,
+  createServiceUserWithId,
 } from '../../../test/test-data-generators';
 import {
   pressAndReleaseKeyOn,
@@ -899,8 +900,8 @@
     await element.updateComplete;
 
     element.reviewers = [
-      {_account_id: 1 as AccountId, _pendingAdd: true},
-      {_account_id: 2 as AccountId, _pendingAdd: true},
+      {_account_id: 1 as AccountId},
+      {_account_id: 2 as AccountId},
     ];
     element.ccs = [];
     element.draftCommentThreads = [];
@@ -929,6 +930,12 @@
       owner: {_account_id: 1 as AccountId},
       status: ChangeStatus.NEW,
       attention_set: {},
+      reviewers: {
+        [ReviewerState.REVIEWER]: [
+          {...createAccountWithId(2)},
+          {...createAccountWithId(3)},
+        ],
+      },
     };
     // let rebuildReviewers triggered by change update finish running
     await element.updateComplete;
@@ -1342,14 +1349,8 @@
     );
 
     // No reviewer/CC should have been added.
-    assert.equal(
-      queryAndAssert<GrAccountList>(element, '#ccs').additions().length,
-      0
-    );
-    assert.equal(
-      queryAndAssert<GrAccountList>(element, '#reviewers').additions().length,
-      0
-    );
+    assert.equal(element.ccsList?.additions().length, 0);
+    assert.equal(element.reviewersList?.additions().length, 0);
 
     // Reopen confirmation dialog.
     observer = overlayObserver('opened');
@@ -1379,17 +1380,11 @@
       isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
     );
     const additions = cc
-      ? queryAndAssert<GrAccountList>(element, '#ccs').additions()
-      : queryAndAssert<GrAccountList>(element, '#reviewers').additions();
+      ? element.ccsList?.additions()
+      : element.reviewersList?.additions();
     assert.deepEqual(additions, [
       {
-        group: {
-          id: 'id' as GroupId,
-          name: 'name' as GroupName,
-          confirmed: true,
-          _group: true,
-          _pendingAdd: true,
-        },
+        name: 'name' as GroupName,
       },
     ]);
 
@@ -1630,28 +1625,22 @@
 
   test('chooseFocusTarget', () => {
     element.account = undefined;
-    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
 
-    element.account = {_account_id: 1 as AccountId};
-    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+    element.account = element.change!.owner;
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.REVIEWERS);
 
-    element.change!.owner = {_account_id: 2 as AccountId};
-    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+    element.change!.reviewers.REVIEWER = [createAccountWithId(314)];
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
 
-    element.change!.owner._account_id = 1 as AccountId;
-    assert.strictEqual(
-      element.chooseFocusTarget(),
-      element.FocusTarget.REVIEWERS
-    );
+    element.change!.reviewers.REVIEWER = [createServiceUserWithId(314)];
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.REVIEWERS);
 
-    element.reviewers = [];
-    assert.strictEqual(
-      element.chooseFocusTarget(),
-      element.FocusTarget.REVIEWERS
-    );
-
-    element.reviewers.push({});
-    assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+    element.change!.reviewers.REVIEWER = [
+      createAccountWithId(314),
+      createServiceUserWithId(314),
+    ];
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
   });
 
   test('only send labels that have changed', async () => {
@@ -2005,6 +1994,8 @@
       [ReviewerState.REVIEWER]: [{_account_id: reviewer1._account_id}],
     };
 
+    await element.updateComplete;
+
     const mutations: ReviewerInput[] = [];
 
     stubSaveReview((review: ReviewInput) => {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index 3564ec9..d7373de 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -6,7 +6,7 @@
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {Action} from '../../api/checks';
-import {checkRequiredProperty} from '../../utils/common-util';
+import {assertIsDefined} from '../../utils/common-util';
 import {resolve} from '../../models/dependency';
 import {checksModelToken} from '../../models/checks/checks-model';
 @customElement('gr-checks-action')
@@ -25,7 +25,7 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    checkRequiredProperty(this.action, 'action');
+    assertIsDefined(this.action, 'action');
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 202c502..edefdd1 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -33,7 +33,7 @@
   secondaryLinks,
   tooltipForLink,
 } from '../../models/checks/checks-util';
-import {assertIsDefined, check} from '../../utils/common-util';
+import {assertIsDefined, assert} from '../../utils/common-util';
 import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
 import {durationString} from '../../utils/date-util';
 import {charsOnly} from '../../utils/string-util';
@@ -1201,7 +1201,7 @@
 
   private onPatchsetSelected(e: CustomEvent<{value: string}>) {
     const patchset = Number(e.detail.value);
-    check(!isNaN(patchset), 'selected patchset must be a number');
+    assert(!isNaN(patchset), 'selected patchset must be a number');
     this.getChecksModel().setPatchset(patchset as PatchSetNumber);
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 1fa21e5..c8a2995 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -15,12 +15,16 @@
   SuggestedReviewerGroupInfo,
   SuggestedReviewerAccountInfo,
   SuggestedReviewerInfo,
+  isGroup,
 } from '../../../types/common';
 import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 import {fire, fireAlert} from '../../../utils/event-util';
-import {accountOrGroupKey} from '../../../utils/account-util';
+import {
+  accountOrGroupKey,
+  isAccountNewlyAdded,
+} from '../../../utils/account-util';
 import {LitElement, css, html, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -31,9 +35,10 @@
   GrAutocomplete,
 } from '../gr-autocomplete/gr-autocomplete';
 import {ValueChangedEvent} from '../../../types/events';
-import {queryAndAssert} from '../../../utils/common-util';
+import {difference, queryAndAssert} from '../../../utils/common-util';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {IronInputElement} from '@polymer/iron-input';
+import {ReviewerState} from '../../../api/rest-api';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
@@ -74,37 +79,18 @@
 
 // Internal input type with account info
 export interface AccountInfoInput extends AccountInfo {
-  _group?: boolean;
   _account?: boolean;
-  _pendingAdd?: boolean;
   confirmed?: boolean;
 }
 
 // Internal input type with group info
 export interface GroupInfoInput extends GroupInfo {
-  _group?: boolean;
   _account?: boolean;
-  _pendingAdd?: boolean;
   confirmed?: boolean;
 }
 
-function isAccountInfoInput(x: AccountInput): x is AccountInfoInput {
-  const input = x as AccountInfoInput;
-  return !!input._account || !!input._account_id || !!input.email;
-}
-
-function isGroupInfoInput(x: AccountInput): x is GroupInfoInput {
-  const input = x as GroupInfoInput;
-  return !!input._group || !!input.id;
-}
-
 export type AccountInput = AccountInfoInput | GroupInfoInput;
 
-export interface AccountAddition {
-  account?: AccountInfoInput;
-  group?: GroupInfoInput;
-}
-
 @customElement('gr-account-list')
 export class GrAccountList extends LitElement {
   /**
@@ -129,6 +115,9 @@
   @property({type: Boolean})
   disabled = false;
 
+  @property({type: String})
+  reviewerState?: ReviewerState;
+
   /**
    * Returns suggestions and convert them to list item
    */
@@ -164,8 +153,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private pendingRemoval: Set<AccountInput> = new Set();
-
   constructor() {
     super();
     this.querySuggestions = input => this.getSuggestions(input);
@@ -190,7 +177,7 @@
       .group {
         --account-label-suffix: ' (group)';
       }
-      .pendingAdd {
+      .newlyAdded {
         font-style: italic;
       }
       .list {
@@ -209,8 +196,12 @@
               .account=${account}
               .change=${this.change}
               class=${classMap({
-                group: !!account._group,
-                pendingAdd: !!account._pendingAdd,
+                group: isGroup(account),
+                newlyAdded: isAccountNewlyAdded(
+                  account,
+                  this.reviewerState,
+                  this.change
+                ),
               })}
               ?removable=${this.computeRemovable(account)}
               @keydown=${this.handleChipKeydown}
@@ -284,8 +275,7 @@
     let group;
     let itemTypeAdded = 'unknown';
     if (isAccountObject(item)) {
-      account = {...item.account, _pendingAdd: true};
-      this.removeFromPendingRemoval(account);
+      account = {...item.account};
       this.accounts.push(account);
       itemTypeAdded = 'account';
     } else if (isSuggestedReviewerGroupInfo(item)) {
@@ -293,9 +283,8 @@
         this.pendingConfirmation = item;
         return;
       }
-      group = {...item.group, _pendingAdd: true, _group: true};
+      group = {...item.group};
       this.accounts.push(group);
-      this.removeFromPendingRemoval(group);
       itemTypeAdded = 'group';
     } else if (this.allowAnyInput) {
       if (!item.includes('@')) {
@@ -305,9 +294,8 @@
         fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
-        account = {email: item as EmailAddress, _pendingAdd: true};
+        account = {email: item as EmailAddress};
         this.accounts.push(account);
-        this.removeFromPendingRemoval(account);
         itemTypeAdded = 'email';
       }
     }
@@ -323,8 +311,6 @@
     this.accounts.push({
       ...group,
       confirmed: true,
-      _pendingAdd: true,
-      _group: true,
     });
     this.pendingConfirmation = null;
     fire(this, 'accounts-changed', {value: this.accounts});
@@ -332,18 +318,6 @@
   }
 
   // private but used in test
-  computeChipClass(account: AccountInput) {
-    const classes = [];
-    if (account._group) {
-      classes.push('group');
-    }
-    if (account._pendingAdd) {
-      classes.push('pendingAdd');
-    }
-    return classes.join(' ');
-  }
-
-  // private but used in test
   computeRemovable(account: AccountInput) {
     if (this.readonly) {
       return false;
@@ -357,7 +331,7 @@
           return true;
         }
       }
-      return !!account._pendingAdd;
+      return isAccountNewlyAdded(account, this.reviewerState, this.change);
     }
     return true;
   }
@@ -375,7 +349,6 @@
     for (let i = 0; i < this.accounts.length; i++) {
       if (accountOrGroupKey(toRemove) === accountOrGroupKey(this.accounts[i])) {
         this.accounts.splice(i, 1);
-        this.pendingRemoval.add(toRemove);
         this.reporting.reportInteraction(`Remove from ${this.id}`);
         this.requestUpdate();
         fire(this, 'accounts-changed', {value: this.accounts.slice()});
@@ -473,37 +446,19 @@
     return wasSubmitted;
   }
 
-  additions(): AccountAddition[] {
-    return this.accounts
-      .filter(account => account._pendingAdd)
-      .map(account => {
-        if (isGroupInfoInput(account)) {
-          return {group: account};
-        } else if (isAccountInfoInput(account)) {
-          return {account};
-        } else {
-          throw new Error('AccountInput must be either Account or Group.');
-        }
-      });
+  additions(): (AccountInfoInput | GroupInfoInput)[] {
+    if (!this.change) return [];
+    return this.accounts.filter(account =>
+      isAccountNewlyAdded(account, this.reviewerState, this.change)
+    );
   }
 
-  removals(): AccountAddition[] {
-    return Array.from(this.pendingRemoval).map(account => {
-      if (isGroupInfoInput(account)) {
-        return {group: account};
-      } else if (isAccountInfoInput(account)) {
-        return {account};
-      } else {
-        throw new Error('AccountInput must be either Account or Group.');
-      }
-    });
-  }
-
-  private removeFromPendingRemoval(account: AccountInput) {
-    this.pendingRemoval.delete(account);
-  }
-
-  clearPendingRemovals() {
-    this.pendingRemoval.clear();
+  removals(): AccountInfo[] {
+    if (!this.reviewerState) return [];
+    return difference(
+      this.change?.reviewers[this.reviewerState] ?? [],
+      this.accounts,
+      (a, b) => accountOrGroupKey(a) === accountOrGroupKey(b)
+    );
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 21b9e59..873b6cf 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -28,6 +28,8 @@
   GrAutocomplete,
 } from '../gr-autocomplete/gr-autocomplete';
 import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
+import {createChange} from '../../../test/test-data-generators';
+import {ReviewerState} from '../../../api/rest-api';
 
 const basicFixture = fixtureFromElement('gr-account-list');
 
@@ -65,7 +67,6 @@
     const groupId = `group${++_nextAccountId}`;
     return {
       id: groupId as GroupId,
-      _group: true,
       name: 'abcd' as GroupName,
     };
   };
@@ -94,6 +95,9 @@
 
     element = basicFixture.instantiate();
     element.accounts = [existingAccount1, existingAccount2];
+    element.reviewerState = ReviewerState.REVIEWER;
+    element.change = {...createChange()};
+    element.change.reviewers[ReviewerState.REVIEWER] = [...element.accounts];
     suggestionsProvider = new MockSuggestionsProvider();
     element.suggestionsProvider = suggestionsProvider;
     await element.updateComplete;
@@ -130,18 +134,18 @@
     // Existing accounts are listed.
     let chips = getChips();
     assert.equal(chips.length, 2);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isFalse(chips[1].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
+    assert.isFalse(chips[1].classList.contains('newlyAdded'));
 
-    // New accounts are added to end with pendingAdd class.
+    // New accounts are added to end with newlyAdded class.
     const newAccount = makeAccount();
     handleAdd({account: newAccount, count: 1});
     await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 3);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isFalse(chips[1].classList.contains('pendingAdd'));
-    assert.isTrue(chips[2].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
+    assert.isFalse(chips[1].classList.contains('newlyAdded'));
+    assert.isTrue(chips[2].classList.contains('newlyAdded'));
 
     // Removed accounts are taken out of the list.
     element.dispatchEvent(
@@ -154,8 +158,8 @@
     await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 2);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
+    assert.isTrue(chips[1].classList.contains('newlyAdded'));
 
     // Invalid remove is ignored.
     element.dispatchEvent(
@@ -175,16 +179,16 @@
     await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 1);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
 
-    // New groups are added to end with pendingAdd and group classes.
+    // New groups are added to end with newlyAdded and group classes.
     const newGroup = makeGroup();
     handleAdd({group: newGroup, confirm: false, count: 1});
     await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 2);
     assert.isTrue(chips[1].classList.contains('group'));
-    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+    assert.isTrue(chips[1].classList.contains('newlyAdded'));
 
     // Removed groups are taken out of the list.
     element.dispatchEvent(
@@ -197,7 +201,7 @@
     await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 1);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
   });
 
   test('getSuggestions uses filter correctly', () => {
@@ -260,20 +264,8 @@
       });
   });
 
-  test('computeChipClass', () => {
-    const account = makeAccount() as AccountInfoInput;
-    assert.equal(element.computeChipClass(account), '');
-    account._pendingAdd = true;
-    assert.equal(element.computeChipClass(account), 'pendingAdd');
-    account._group = true;
-    assert.equal(element.computeChipClass(account), 'group pendingAdd');
-    account._pendingAdd = false;
-    assert.equal(element.computeChipClass(account), 'group');
-  });
-
   test('computeRemovable', async () => {
     const newAccount = makeAccount() as AccountInfoInput;
-    newAccount._pendingAdd = true;
     element.readonly = false;
     element.removableValues = [];
     element.updateComplete;
@@ -320,7 +312,7 @@
     assert.isTrue(element.submitEntryText());
     assert.isTrue(clearStub.called);
     assert.equal(
-      element.additions()[0].account?.email,
+      (element.additions()[0] as AccountInfo)?.email,
       'test@test' as EmailAddress
     );
   });
@@ -335,18 +327,11 @@
 
     assert.deepEqual(element.additions(), [
       {
-        account: {
-          _account_id: newAccount._account_id,
-          _pendingAdd: true,
-        },
+        _account_id: newAccount._account_id,
       },
       {
-        group: {
-          id: newGroup.id,
-          _group: true,
-          _pendingAdd: true,
-          name: 'abcd' as GroupName,
-        },
+        id: newGroup.id,
+        name: 'abcd' as GroupName,
       },
     ]);
   });
@@ -356,7 +341,7 @@
     assert.deepEqual(element.additions(), []);
 
     const group = makeGroup();
-    const reviewer = {
+    const reviewer: RawAccountInput = {
       group,
       count: 10,
       confirm: true,
@@ -370,13 +355,9 @@
     assert.isNull(element.pendingConfirmation);
     assert.deepEqual(element.additions(), [
       {
-        group: {
-          id: group.id,
-          _group: true,
-          _pendingAdd: true,
-          confirmed: true,
-          name: 'abcd' as GroupName,
-        },
+        id: group.id,
+        name: 'abcd' as GroupName,
+        confirmed: true,
       },
     ]);
   });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index eac76da..5a34ae3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -6,7 +6,7 @@
 import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
 import {LineRange, Side} from '../../../api/diff';
 import {LineNumber} from './gr-diff-line';
-import {assertIsDefined, check} from '../../../utils/common-util';
+import {assertIsDefined, assert} from '../../../utils/common-util';
 import {untilRendered} from '../../../utils/dom-util';
 
 export enum GrDiffGroupType {
@@ -267,7 +267,7 @@
           };
         } else {
           assertIsDefined(options.lines);
-          check(options.lines.length > 0, 'diff group must have lines');
+          assert(options.lines.length > 0, 'diff group must have lines');
           for (const line of options.lines) {
             this.addLine(line);
           }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index e7e85c2..81ca830 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -72,6 +72,7 @@
 } from '../types/common';
 import {
   AccountsVisibility,
+  AccountTag,
   AppTheme,
   AuthType,
   ChangeStatus,
@@ -188,6 +189,13 @@
   };
 }
 
+export function createServiceUserWithId(id = 5): AccountInfo {
+  return {
+    ...createAccountWithId(id),
+    tags: [AccountTag.SERVICE_USER],
+  };
+}
+
 export function createAccountDetailWithId(id = 5): AccountDetailInfo {
   return {
     _account_id: id as AccountId,
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 9b8d82a..012dc1c 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -19,7 +19,6 @@
 } from '../types/common';
 import {AccountTag, ReviewerState} from '../constants/constants';
 import {assertNever, hasOwnProperty} from './common-util';
-import {AccountAddition} from '../elements/shared/gr-account-list/gr-account-list';
 import {getDisplayName} from './display-name-util';
 import {getApprovalInfo} from './label-util';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
@@ -36,30 +35,6 @@
   throw new Error('Account has neither _account_id nor email.');
 }
 
-export function mapReviewer(addition: AccountAddition): ReviewerInput {
-  if (addition.account) {
-    return {reviewer: accountKey(addition.account)};
-  }
-  if (addition.group) {
-    const reviewer = decodeURIComponent(addition.group.id) as GroupId;
-    const confirmed = addition.group.confirmed;
-    return {reviewer, confirmed};
-  }
-  throw new Error('Reviewer must be either an account or a group.');
-}
-
-export function isReviewerOrCC(
-  change: ChangeInfo,
-  reviewerAddition: AccountAddition
-): boolean {
-  const reviewers = [
-    ...(change.reviewers[ReviewerState.CC] ?? []),
-    ...(change.reviewers[ReviewerState.REVIEWER] ?? []),
-  ];
-  const reviewer = mapReviewer(reviewerAddition);
-  return reviewers.some(r => accountOrGroupKey(r) === reviewer.reviewer);
-}
-
 export function isServiceUser(account?: AccountInfo): boolean {
   return !!account?.tags?.includes(AccountTag.SERVICE_USER);
 }
@@ -82,6 +57,18 @@
   assertNever(entry, 'entry must be account or group');
 }
 
+export function isAccountNewlyAdded(
+  account: AccountInfo | GroupInfo,
+  state?: ReviewerState,
+  change?: ChangeInfo
+) {
+  if (!change || !state) return false;
+  const accounts = [...(change.reviewers[state] ?? [])];
+  return !accounts.some(
+    a => accountOrGroupKey(a) === accountOrGroupKey(account)
+  );
+}
+
 export function uniqueDefinedAvatar(
   account: AccountInfo,
   index: number,
@@ -221,3 +208,19 @@
   }
   return emails;
 }
+
+export function toReviewInput(
+  account: AccountInfo | GroupInfo,
+  state: ReviewerState
+): ReviewerInput {
+  if (isAccount(account)) {
+    return {
+      reviewer: accountKey(account),
+      state,
+    };
+  } else if (isGroup(account)) {
+    const reviewer = decodeURIComponent(account.id) as GroupId;
+    return {reviewer, state};
+  }
+  throw new Error('Must be either an account or a group.');
+}
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index d6d2f7d..cf0ff53 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -14,6 +14,7 @@
 } from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
 import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
+import {isServiceUser} from './account-util';
 
 // This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
@@ -249,6 +250,11 @@
   );
 }
 
+export function hasHumanReviewer(change?: ChangeInfo): boolean {
+  const reviewers = change?.reviewers.REVIEWER ?? [];
+  return reviewers.some(r => !isServiceUser(r));
+}
+
 export function isRemovableReviewer(
   change?: ChangeInfo,
   reviewer?: AccountInfo
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index fe86d91..0bae49a 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -38,7 +38,7 @@
 /**
  * Throws an error with the provided error message if the condition is false.
  */
-export function check(
+export function assert(
   condition: boolean,
   errorMessage: string
 ): asserts condition {
@@ -48,28 +48,6 @@
 /**
  * Throws an error if the property is not defined.
  */
-export function checkProperty(
-  condition: boolean,
-  propertyName: string
-): asserts condition {
-  check(condition, `missing required property '${propertyName}'`);
-}
-
-/**
- * Throws an error if the property is not defined.
- */
-export function checkRequiredProperty<T>(
-  property: T,
-  propertyName: string
-): asserts property is NonNullable<T> {
-  if (property === undefined || property === null) {
-    throw new Error(`Required property '${propertyName}' not set.`);
-  }
-}
-
-/**
- * Throws an error if the property is not defined.
- */
 export function assertIsDefined<T>(
   val: T,
   variableName = 'variable'
@@ -168,3 +146,14 @@
     result.filter(t => array.find(u => compareBy(t, u)))
   );
 }
+
+/**
+ * Returns the elements that are present in A but not present in B.
+ */
+export function difference<T>(
+  a: T[],
+  b: T[],
+  compareBy: (t: T, u: T) => boolean = (t, u) => t === u
+): T[] {
+  return a.filter(aVal => !b.some(bVal => compareBy(aVal, bVal)));
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.ts b/polygerrit-ui/app/utils/common-util_test.ts
index e07401b..c9ba060 100644
--- a/polygerrit-ui/app/utils/common-util_test.ts
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -9,6 +9,7 @@
   areSetsEqual,
   containsAll,
   intersection,
+  difference,
 } from './common-util';
 
 suite('common-util tests', () => {
@@ -88,4 +89,11 @@
       [foo1]
     );
   });
+
+  test('difference', () => {
+    assert.deepEqual(difference([1, 2, 3], []), [1, 2, 3]);
+    assert.deepEqual(difference([1, 2, 3], [2, 3, 4]), [1]);
+    assert.deepEqual(difference([1, 2, 3], [1, 2, 3]), []);
+    assert.deepEqual(difference([1, 2, 3], [4, 5, 6]), [1, 2, 3]);
+  });
 });
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index f8ba4c3..515f2e7 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -9,7 +9,7 @@
   RevisionPatchSetNum,
 } from '../types/common';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
-import {check} from './common-util';
+import {assert} from './common-util';
 
 /**
  * @license
@@ -261,7 +261,7 @@
   if (latest === EDIT) {
     latest = allPatchSets[1].num;
   }
-  check(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
+  assert(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
   return latest;
 }
 
diff --git a/polygerrit-ui/app/utils/service-worker-util.ts b/polygerrit-ui/app/utils/service-worker-util.ts
index eb547ea..3cf0dce 100644
--- a/polygerrit-ui/app/utils/service-worker-util.ts
+++ b/polygerrit-ui/app/utils/service-worker-util.ts
@@ -15,8 +15,9 @@
 export function filterAttentionChangesAfter(
   changes: ParsedChangeInfo[],
   account: AccountDetailInfo,
-  latestUpdateTimestampMs: number
+  latestUpdateTimestampMs?: number
 ) {
+  if (!latestUpdateTimestampMs) return changes;
   return changes.filter(change => {
     const attention_set = change.attention_set![account._account_id!];
     if (!attention_set.last_update) return false;
diff --git a/polygerrit-ui/app/workers/service-worker-class.ts b/polygerrit-ui/app/workers/service-worker-class.ts
new file mode 100644
index 0000000..966b6b0
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker-class.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {ParsedChangeInfo} from '../types/types';
+import {getReason} from '../utils/attention-set-util';
+import {readResponsePayload} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {filterAttentionChangesAfter} from '../utils/service-worker-util';
+import {AccountDetailInfo} from '../api/rest-api';
+import {TRIGGER_NOTIFICATION_UPDATES_MS} from '../services/service-worker-installer';
+
+export class ServiceWorker {
+  constructor(private ctx: ServiceWorkerGlobalScope) {}
+
+  latestUpdateTimestampMs?: number;
+
+  showNotification(change: ParsedChangeInfo, account: AccountDetailInfo) {
+    const body = getReason(undefined, account, change);
+    // TODO(milutin): Implement event.action that
+    // focus on firstWindowClient and open change there.
+    // TODO(milutin): Add gerrit host icon
+    this.ctx.registration.showNotification(change.subject, {body});
+  }
+
+  async getChangesToNotify(account: AccountDetailInfo) {
+    // We throttle polling, since there can be many clients triggerring
+    // always only one service worker.
+    if (this.latestUpdateTimestampMs) {
+      const durationFromLatestUpdateMS =
+        Date.now() - this.latestUpdateTimestampMs;
+      if (durationFromLatestUpdateMS < TRIGGER_NOTIFICATION_UPDATES_MS) {
+        return [];
+      }
+    }
+    const changes = await this.getLatestAttentionSetChanges();
+    const latestAttentionChanges = filterAttentionChangesAfter(
+      changes,
+      account,
+      this.latestUpdateTimestampMs
+    );
+    this.latestUpdateTimestampMs = Date.now();
+    return latestAttentionChanges;
+  }
+
+  async getLatestAttentionSetChanges(): Promise<ParsedChangeInfo[]> {
+    // TODO(milutin): Implement more generic query builder
+    const response = await fetch(
+      '/changes/?O=1000081&S=0&n=25&q=attention%3Aself'
+    );
+    const payload = await readResponsePayload(response);
+    const changes = payload.parsed as unknown as ParsedChangeInfo[] | undefined;
+    return changes ?? [];
+  }
+}
diff --git a/polygerrit-ui/app/workers/service-worker-class_test.ts b/polygerrit-ui/app/workers/service-worker-class_test.ts
new file mode 100644
index 0000000..ecfef5e
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker-class_test.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Timestamp} from '../api/rest-api';
+import '../test/common-test-setup-karma';
+import {
+  createAccountDetailWithId,
+  createParsedChange,
+} from '../test/test-data-generators';
+import {ParsedChangeInfo} from '../types/types';
+import {parseDate} from '../utils/date-util';
+import {ServiceWorker} from './service-worker-class';
+
+suite('service worker class tests', () => {
+  let serviceWorker: ServiceWorker;
+
+  setup(() => {
+    const moctCtx = {
+      registration: {
+        showNotification: () => {},
+      },
+    } as {} as ServiceWorkerGlobalScope;
+    serviceWorker = new ServiceWorker(moctCtx);
+  });
+
+  test('notify about attention in t2 when time is t3', async () => {
+    const t1 = parseDate('2016-01-12 20:20:00' as Timestamp).getTime();
+    const t2 = '2016-01-12 20:30:00' as Timestamp;
+    const t3 = parseDate('2016-01-12 20:40:00' as Timestamp).getTime();
+    serviceWorker.latestUpdateTimestampMs = t1;
+    const account = createAccountDetailWithId();
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: t2,
+        },
+      },
+    };
+    sinon.useFakeTimers(t3);
+    sinon
+      .stub(serviceWorker, 'getLatestAttentionSetChanges')
+      .returns(Promise.resolve([change]));
+    const changes = await serviceWorker.getChangesToNotify(account);
+    assert.equal(changes[0], change);
+  });
+});
diff --git a/polygerrit-ui/app/workers/service-worker.ts b/polygerrit-ui/app/workers/service-worker.ts
index 47bcbd1..ca1f5dc 100644
--- a/polygerrit-ui/app/workers/service-worker.ts
+++ b/polygerrit-ui/app/workers/service-worker.ts
@@ -5,14 +5,8 @@
  */
 
 import {AccountDetailInfo} from '../api/rest-api';
-import {readResponsePayload} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
-import {
-  ServiceWorkerMessageType,
-  TRIGGER_NOTIFICATION_UPDATES_MS,
-} from '../services/service-worker-installer';
-import {ParsedChangeInfo} from '../types/types';
-import {getReason} from '../utils/attention-set-util';
-import {filterAttentionChangesAfter} from '../utils/service-worker-util';
+import {ServiceWorkerMessageType} from '../services/service-worker-installer';
+import {ServiceWorker} from './service-worker-class';
 
 /**
  * `self` is for a worker what `window` is for the web app. It is called
@@ -38,46 +32,5 @@
   }
 });
 
-class ServiceWorker {
-  latestUpdateTimestampMs?: number;
-
-  showNotification(change: ParsedChangeInfo, account: AccountDetailInfo) {
-    const body = getReason(undefined, account, change);
-    // TODO(milutin): Implement event.action that
-    // focus on firstWindowClient and open change there.
-    // TODO(milutin): Add gerrit host icon
-    ctx.registration.showNotification(change.subject, {body});
-  }
-
-  async getChangesToNotify(account: AccountDetailInfo) {
-    const changes = await serviceWorker.getLatestAttentionSetChanges();
-    return filterAttentionChangesAfter(
-      changes,
-      account,
-      this.latestUpdateTimestampMs!
-    );
-  }
-
-  async getLatestAttentionSetChanges(): Promise<ParsedChangeInfo[]> {
-    // We throttle polling, since there can be many clients triggerring
-    // always only one service worker.
-    if (this.latestUpdateTimestampMs) {
-      const durationFromLatestUpdateMS =
-        Date.now() - this.latestUpdateTimestampMs;
-      if (durationFromLatestUpdateMS < TRIGGER_NOTIFICATION_UPDATES_MS) {
-        return [];
-      }
-    }
-    this.latestUpdateTimestampMs = Date.now();
-    // TODO(milutin): Implement more generic query builder
-    const response = await fetch(
-      '/changes/?O=1000081&S=0&n=25&q=attention%3Aself'
-    );
-    const payload = await readResponsePayload(response);
-    const changes = payload.parsed as unknown as ParsedChangeInfo[] | undefined;
-    return changes ?? [];
-  }
-}
-
 /** Singleton instance being referenced in `onmessage` function above. */
-const serviceWorker = new ServiceWorker();
+const serviceWorker = new ServiceWorker(ctx);