Merge "Refactor getUrlForChange to take options"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 58a3724..64aa6e0 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -7111,6 +7111,9 @@
 Additional information about whom to notify about the update as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
+|`ignore_automatic_attention_set_rules`|optional|
+If set to true, ignore all automatic attention set rules described in the
+link:#attention-set[attention set]. When not set, the default is false.
 |=============================
 
 [[description-input]]
diff --git a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
index ee10a1d..8432c8f 100644
--- a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
@@ -25,4 +25,10 @@
   public NotifyHandling notify = NotifyHandling.ALL;
 
   public Map<RecipientType, NotifyInfo> notifyDetails;
+
+  /**
+   * Users in the attention set will not be added/removed from this endpoint call. Normally, users
+   * are added to the attention set upon deletion of their vote by other users.
+   */
+  public boolean ignoreAutomaticAttentionSetRules;
 }
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index 6e640f3..b887323 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -32,7 +31,8 @@
 
 /**
  * Limits the total size of all comments and change messages to prevent space/time complexity
- * issues. Note that autogenerated change messages are not subject to validation.
+ * issues. Note that autogenerated change messages are not subject to validation. However, we still
+ * count autogenerated messages for the limit (which will be notified on a further comment).
  */
 public class CommentCumulativeSizeValidator implements CommentValidator {
   public static final int DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT = 3 << 20;
@@ -60,17 +60,11 @@
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .sum()
-            + notes.getChangeMessages().stream()
-                // Auto-generated change messages are not counted for the limit. This method is not
-                // called when those change messages are created, but we should also skip them when
-                // counting the size for unrelated messages.
-                .filter(cm -> !ChangeMessagesUtil.isAutogenerated(cm.getTag()))
-                .mapToInt(cm -> cm.getMessage().length())
-                .sum();
+            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
     int newCumulativeSize =
         comments.stream().mapToInt(CommentForValidation::getApproximateSize).sum();
     ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
-    if (!comments.isEmpty() && existingCumulativeSize + newCumulativeSize > maxCumulativeSize) {
+    if (!comments.isEmpty() && !isEnoughSpace(notes, newCumulativeSize, maxCumulativeSize)) {
       // This warning really applies to the set of all comments, but we need to pick one to attach
       // the message to.
       CommentForValidation commentForFailureMessage = Iterables.getLast(comments);
@@ -84,4 +78,19 @@
     }
     return failures.build();
   }
+
+  /**
+   * Returns {@code true} if there is available space and the new size that we wish to add is less
+   * than the maximum allowed size. {@code false} otherwise (if there is not enough space).
+   */
+  public static boolean isEnoughSpace(ChangeNotes notes, int addedBytes, int maxCumulativeSize) {
+    int existingCumulativeSize =
+        Stream.concat(
+                    notes.getHumanComments().values().stream(),
+                    notes.getRobotComments().values().stream())
+                .mapToInt(Comment::getApproximateSize)
+                .sum()
+            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
+    return existingCumulativeSize + addedBytes < maxCumulativeSize;
+  }
 }
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 572d73d..c918e3d 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -70,6 +70,7 @@
  */
 public class SubmitWithStickyApprovalDiff {
   private static final int HEAP_EST_SIZE = 32 * 1024;
+  private static final int DEFAULT_POST_SUBMIT_SIZE_LIMIT = 300 * 1024; // 300 KiB
 
   private final DiffOperations diffOperations;
   private final ProjectCache projectCache;
@@ -88,6 +89,15 @@
     this.projectCache = projectCache;
     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
     this.repositoryManager = repositoryManager;
+    // (November 2021) We define the max cumulative comment size to 300 KIB since it's a reasonable
+    // size that is large enough for all purposes but not too large to choke the change index by
+    // exceeding the cumulative comment size limit (new comments are not allowed once the limit
+    // is reached). At Google, the change index limit is 5MB, while the cumulative size limit is
+    // set at 3MB. In this example, we can reach at most 3.3MB hence we ensure not to exceed the
+    // limit of 5MB.
+    // The reason we exclude the post submit diff from the cumulative comment size limit is
+    // just because change messages not currently being validated. Change messages are still
+    // counted towards the limit, though.
     maxCumulativeSize =
         serverConfig.getInt(
             "change",
@@ -129,7 +139,9 @@
 
     diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
     TemporaryBuffer.Heap buffer =
-        new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxCumulativeSize), maxCumulativeSize);
+        new TemporaryBuffer.Heap(
+            Math.min(HEAP_EST_SIZE, DEFAULT_POST_SUBMIT_SIZE_LIMIT),
+            DEFAULT_POST_SUBMIT_SIZE_LIMIT);
     try (Repository repository = repositoryManager.openRepository(notes.getProjectName());
         DiffFormatter formatter = new DiffFormatter(buffer)) {
       formatter.setRepository(repository);
@@ -150,6 +162,12 @@
           throw e;
         }
       }
+      if (formatterResult != null) {
+        int addedBytes = formatterResult.stream().mapToInt(String::length).sum();
+        if (!CommentCumulativeSizeValidator.isEnoughSpace(notes, addedBytes, maxCumulativeSize)) {
+          isDiffTooLarge = true;
+        }
+      }
       for (FileDiffOutput fileDiff : modifiedFilesList) {
         diff.append(
             getDiffForFile(
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 45d1f5a..4387524 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
+import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
@@ -146,7 +147,8 @@
               r.getReviewerUser().state(),
               rsrc.getLabel(),
               input));
-      if (!r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
+      if (!input.ignoreAutomaticAttentionSetRules
+          && !r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
         bu.addOp(
             change.getId(),
             attentionSetOpFactory.create(
@@ -154,6 +156,9 @@
                 /* reason= */ "Their vote was deleted",
                 /* notify= */ false));
       }
+      if (input.ignoreAutomaticAttentionSetRules) {
+        bu.addOp(change.getId(), new AttentionSetUnchangedOp());
+      }
       bu.execute();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index 5124d11..29fdc15 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.collect.ImmutableList;
@@ -35,6 +36,7 @@
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import java.util.HashSet;
@@ -345,8 +347,8 @@
   }
 
   @Test
-  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
-  public void autoGeneratedPostSubmitDiffIsNotPartOfTheCommentSizeLimit() throws Exception {
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "10k")
+  public void autoGeneratedPostSubmitDiffIsPartOfTheCommentSizeLimit() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
@@ -356,35 +358,69 @@
     // Post a submit diff that is almost the cumulativeCommentSizeLimit
     gApi.changes().id(changeId.get()).current().submit();
     assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
-        .doesNotContain("many unreviewed changes");
+        .doesNotContain("The diff is too large to show. Please review the diff");
 
-    // unrelated comment and change message posting works fine, since the post submit diff is not
+    // unrelated comment and change message posting doesn't work, since the post submit diff is
     // counted towards the cumulativeCommentSizeLimit for unrelated follow-up comments.
-    // 800 + 400 + 400 > 1k, but 400 + 400 < 1k, hence these comments are accepted (the original
-    // 800 is not counted).
-    String message = new String(new char[400]).replace("\0", "a");
+    // 800 + 9500 > 10k.
+    String message = new String(new char[9500]).replace("\0", "a");
     ReviewInput reviewInput = new ReviewInput().message(message);
     CommentInput commentInput = new CommentInput();
     commentInput.line = 1;
-    commentInput.message = message;
     commentInput.path = "file";
     reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
 
-    gApi.changes().id(changeId.get()).current().review(reviewInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId.get()).current().review(reviewInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Exceeding maximum cumulative size of comments and change messages");
   }
 
   @Test
-  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
   public void postSubmitDiffCannotBeTooBig() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
 
-    String content = new String(new char[1100]).replace("\0", "a");
+    // max post submit diff size is 300k
+    String content = new String(new char[320000]).replace("\0", "a");
 
     changeOperations.change(changeId).newPatchset().file("file").content(content).create();
 
-    // Post submit diff is over the cumulativeCommentSizeLimit, so we shorten the message.
+    // Post submit diff is over the postSubmitDiffSizeLimit (300k).
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo(
+            "Change has been successfully merged\n\n1 is the latest approved patch-set.\nThe "
+                + "change was submitted with unreviewed changes in the following "
+                + "files:\n\n```\nThe name of the file: file\nInsertions: 1, Deletions: 1.\n\nThe"
+                + " diff is too large to show. Please review the diff.\n```\n");
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "10k")
+  public void postSubmitDiffCannotBeTooBigWithLargeComments() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    // unrelated comment taking up most of the space, making post submit diff shorter.
+    String message = new String(new char[9700]).replace("\0", "a");
+    ReviewInput reviewInput = new ReviewInput().message(message);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.path = "file";
+    reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+
+    String content = new String(new char[500]).replace("\0", "a");
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post submit diff is over the cumulativeCommentSizeLimit, since the comment took most of
+    // the space (even though the post submit diff is not limited).
     gApi.changes().id(changeId.get()).current().submit();
     assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
         .isEqualTo(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 4bce5d8..9246442 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
@@ -1941,6 +1942,30 @@
   }
 
   @Test
+  public void deleteVotesDoesNotAffectAttentionSetWhenIgnoreAutomaticRulesIsSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    DeleteVoteInput deleteVoteInput = new DeleteVoteInput();
+    deleteVoteInput.label = LabelId.CODE_REVIEW;
+
+    // set this to true to not change the attention set.
+    deleteVoteInput.ignoreAutomaticAttentionSetRules = true;
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .reviewer(user.id().toString())
+        .deleteVote(deleteVoteInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
   public void deleteVotesOfOthersAddThemToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 47c820a..a7a615fc 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -101,7 +101,6 @@
     "elements/admin/gr-repo-access/gr-repo-access_html.ts",
     "elements/admin/gr-repo-commands/gr-repo-commands_html.ts",
     "elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts",
-    "elements/admin/gr-repo/gr-repo_html.ts",
     "elements/admin/gr-rule-editor/gr-rule-editor_html.ts",
     "elements/change-list/gr-change-list-view/gr-change-list-view_html.ts",
     "elements/change-list/gr-change-list/gr-change-list_html.ts",
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 905d6be..90d9289 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -484,5 +484,20 @@
 
   createCommentInPlace(): void;
   resetScrollMode(): void;
-  moveToLineNumber(lineNum: number, side: Side, path?: string): void;
+
+  /**
+   * Moves to a specific line number in the diff
+   *
+   * @param lineNum which line number should be selected
+   * @param side which side should be selected
+   * @param path file path for the file that should be selected
+   * @param intentionalMove Defines if move-related controls should be applied
+   * (e.g. GrCursorManager.focusOnMove)
+   **/
+  moveToLineNumber(
+    lineNum: number,
+    side: Side,
+    path?: string,
+    intentionalMove?: boolean
+  ): void;
 }
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index e2b1502..0c47cdc 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -524,6 +524,7 @@
   plugin_config?: PluginNameToPluginParametersMap;
   actions?: {[viewName: string]: ActionInfo};
   reject_empty_commit?: InheritedBooleanInfo;
+  enable_reviewer_by_email: InheritedBooleanInfo;
 }
 
 export declare interface ConfigListParameterInfo
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index 2328a05..48f42a4 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -39,6 +39,7 @@
   CapabilityInfoMap,
   GitRef,
   LabelNameToLabelTypeInfoMap,
+  RepoName,
 } from '../../../types/common';
 import {PolymerDomRepeatEvent} from '../../../types/types';
 import {fireEvent} from '../../../utils/event-util';
@@ -75,6 +76,9 @@
     return htmlTemplate;
   }
 
+  @property({type: String})
+  repo?: RepoName;
+
   @property({type: Object})
   capabilities?: CapabilityInfoMap;
 
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
index 1438825..65a3199 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
@@ -123,6 +123,7 @@
             section="[[section.id]]"
             editing="[[editing]]"
             groups="[[groups]]"
+            repo="[[repo]]"
             on-added-permission-removed="_handleAddedPermissionRemoved"
           >
           </gr-permission>
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index b7ea237..4d5ecd3 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -39,6 +39,7 @@
   ProjectAccessGroups,
   GroupId,
   GitRef,
+  RepoName,
 } from '../../../types/common';
 import {
   AutocompleteQuery,
@@ -98,6 +99,9 @@
     return htmlTemplate;
   }
 
+  @property({type: String})
+  repo?: RepoName;
+
   @property({type: Object})
   labels?: LabelNameToLabelTypeInfoMap;
 
@@ -320,7 +324,11 @@
 
   _getGroupSuggestions(): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getSuggestedGroups(this._groupFilter || '', MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedGroups(
+        this._groupFilter || '',
+        this.repo,
+        MAX_AUTOCOMPLETE_RESULTS
+      )
       .then(response => {
         const groups: GroupSuggestion[] = [];
         for (const [name, value] of Object.entries(response ?? {})) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
index 65f0564..8f88619 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
@@ -116,6 +116,7 @@
           editing="[[_editing]]"
           owner-of="[[_ownerOf]]"
           groups="[[_groups]]"
+          repo="[[repo]]"
           on-added-section-removed="_handleAddedSectionRemoved"
         ></gr-access-section>
       </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index 7092c9b..37c88f2 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -43,7 +43,6 @@
 export interface ConfigChangeInfo {
   _key: string; // parameterName of PluginParameterToConfigParameterInfoMap
   info: ConfigParameterInfo;
-  notifyPath: string;
 }
 
 export interface PluginData {
@@ -54,7 +53,6 @@
 export interface PluginConfigChangeDetail {
   name: string; // parameterName of PluginParameterToConfigParameterInfoMap
   config: PluginParameterToConfigParameterInfoMap;
-  notifyPath: string;
 }
 
 @customElement('gr-repo-plugin-config')
@@ -248,7 +246,6 @@
     return {
       _key,
       info,
-      notifyPath: `${_key}.value`,
     };
   }
 
@@ -256,7 +253,7 @@
     this._handleChange(e.detail);
   }
 
-  _handleChange({_key, info, notifyPath}: ConfigChangeInfo) {
+  _handleChange({_key, info}: ConfigChangeInfo) {
     // If pluginData is not set, editors are not created and this method
     // can't be called
     const {name, config} = this.pluginData!;
@@ -265,7 +262,6 @@
     const detail: PluginConfigChangeDetail = {
       name,
       config: {...config, [_key]: info},
-      notifyPath: `${name}.${notifyPath}`,
     };
 
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
index 8c2e6b3..4076b747f 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
@@ -44,7 +44,6 @@
     element._handleChange({
       _key: 'plugin',
       info: {value: 'newTest'},
-      notifyPath: 'plugin.value',
     });
 
     assert.isTrue(eventStub.called);
@@ -52,7 +51,6 @@
     const {detail} = eventStub.lastCall.args[0];
     assert.equal(detail.name, 'testName');
     assert.deepEqual(detail.config, {plugin: {value: 'newTest'}});
-    assert.equal(detail.notifyPath, 'testName.plugin.value');
   });
 
   suite('option types', () => {
@@ -151,7 +149,6 @@
     const detail = element._buildConfigChangeInfo('newTest', 'plugin');
     assert.equal(detail._key, 'plugin');
     assert.deepEqual(detail.info, {value: 'newTest'});
-    assert.equal(detail.notifyPath, 'plugin.value');
   });
 });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index cd5b095..bf0b393 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -14,38 +14,42 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '@polymer/iron-input/iron-input';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-button/gr-button';
 import '../../shared/gr-download-commands/gr-download-commands';
 import '../../shared/gr-select/gr-select';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
+import '../../shared/gr-textarea/gr-textarea';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe} from '@polymer/decorators';
 import {
   ConfigInfo,
   RepoName,
   InheritedBooleanInfo,
   SchemesInfoMap,
   ConfigInput,
+  MaxObjectSizeLimitInfo,
   PluginParameterToConfigParameterInfoMap,
-  PluginNameToPluginParametersMap,
 } from '../../../types/common';
-import {PluginData} from '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {ProjectState} from '../../../constants/constants';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {
+  InheritedBooleanInfoConfiguredValue,
+  ProjectState,
+  SubmitType,
+} from '../../../constants/constants';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {WebLinkInfo} from '../../../types/diff';
 import {ErrorCallback} from '../../../api/rest';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {BindValueChangeEvent} from '../../../types/events';
+import {deepClone} from '../../../utils/object-util';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
 
 const STATES = {
   active: {value: ProjectState.ACTIVE, label: 'Active'},
@@ -81,92 +85,669 @@
   },
 };
 
-@customElement('gr-repo')
-export class GrRepo extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo': GrRepo;
   }
+}
+
+@customElement('gr-repo')
+export class GrRepo extends LitElement {
+  private schemes: string[] = [];
 
   @property({type: String})
   repo?: RepoName;
 
-  @property({type: Boolean})
-  _configChanged = false;
+  /* private but used in test */
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _loading = true;
+  /* private but used in test */
+  @state() repoConfig?: ConfigInfo;
 
-  @property({type: Boolean, observer: '_loggedInChanged'})
-  _loggedIn = false;
+  /* private but used in test */
+  @state() readOnly = true;
 
-  @property({type: Object})
-  _repoConfig?: ConfigInfo;
+  @state() private states = Object.values(STATES);
 
-  @property({
-    type: Array,
-    computed: '_computePluginData(_repoConfig.plugin_config.*)',
-  })
-  _pluginData?: PluginData[];
+  @state() private originalConfig?: ConfigInfo;
 
-  @property({type: Boolean})
-  _readOnly = true;
+  @state() private selectedScheme?: string;
 
-  @property({type: Array})
-  _states = Object.values(STATES);
+  /* private but used in test */
+  @state() schemesObj?: SchemesInfoMap;
 
-  @property({
-    type: Array,
-    computed: '_computeSchemes(_schemesDefault, _schemesObj)',
-    observer: '_schemesChanged',
-  })
-  _schemes: string[] = [];
+  @state() private weblinks: WebLinkInfo[] = [];
 
-  // This is workaround to have _schemes with default value [],
-  // because assignment doesn't work when property has a computed attribute.
-  @property({type: Array})
-  _schemesDefault: string[] = [];
-
-  @property({type: String})
-  _selectedCommand = 'Clone';
-
-  @property({type: String})
-  _selectedScheme?: string;
-
-  @property({type: Object})
-  _schemesObj?: SchemesInfoMap;
-
-  @property({type: Array})
-  weblinks: WebLinkInfo[] = [];
+  @state() private pluginConfigChanged = false;
 
   private readonly restApiService = appContext.restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadRepo();
+    this.loadRepo();
 
     fireTitleChange(this, `${this.repo}`);
   }
 
-  _computePluginData(
-    configRecord: PolymerDeepPropertyChange<
-      PluginNameToPluginParametersMap,
-      PluginNameToPluginParametersMap
-    >
-  ) {
-    if (!configRecord || !configRecord.base) {
-      return [];
-    }
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      subpageStyles,
+      sharedStyles,
+      css`
+        .info {
+          margin-bottom: var(--spacing-xl);
+        }
+        h2.edited:after {
+          color: var(--deemphasized-text-color);
+          content: ' *';
+        }
+        .loading,
+        .hide {
+          display: none;
+        }
+        #loading.loading {
+          display: block;
+        }
+        #loading:not(.loading) {
+          display: none;
+        }
+        #options .repositorySettings {
+          display: none;
+        }
+        #options .repositorySettings.showConfig {
+          display: block;
+        }
+      `,
+    ];
+  }
 
-    const pluginConfig = configRecord.base;
+  override render() {
+    const configChanged = this.hasConfigChanged();
+    return html`
+      <div class="main gr-form-styles read-only">
+        <div class="info">
+          <h1 id="Title" class="heading-1">${this.repo}</h1>
+          <hr />
+          <div>
+            <a href=${this.weblinks?.[0]?.url}
+              ><gr-button link ?disabled=${!this.weblinks?.[0]?.url}
+                >Browse</gr-button
+              ></a
+            ><a href=${this.computeChangesUrl(this.repo)}
+              ><gr-button link>View Changes</gr-button></a
+            >
+          </div>
+        </div>
+        <div id="loading" class=${this.loading ? 'loading' : ''}>
+          Loading...
+        </div>
+        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
+          ${this.renderDownloadCommands()}
+          <h2
+            id="configurations"
+            class="heading-2 ${configChanged ? 'edited' : ''}"
+          >
+            Configurations
+          </h2>
+          <div id="form">
+            <fieldset>
+              ${this.renderDescription()} ${this.renderRepoOptions()}
+              ${this.renderPluginConfig()}
+              <gr-button
+                ?disabled=${this.readOnly || !configChanged}
+                @click=${this.handleSaveRepoConfig}
+                >Save changes</gr-button
+              >
+            </fieldset>
+            <gr-endpoint-decorator name="repo-config">
+              <gr-endpoint-param
+                name="repoName"
+                .value=${this.repo}
+              ></gr-endpoint-param>
+              <gr-endpoint-param
+                name="readOnly"
+                .value=${this.readOnly}
+              ></gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderDownloadCommands() {
+    return html`
+      <div
+        id="downloadContent"
+        class=${!this.schemes || !this.schemes.length ? 'hide' : ''}
+      >
+        <h2 id="download" class="heading-2">Download</h2>
+        <fieldset>
+          <gr-download-commands
+            id="downloadCommands"
+            .commands=${this.computeCommands(
+              this.repo,
+              this.schemesObj,
+              this.selectedScheme
+            )}
+            .schemes=${this.schemes}
+            .selectedScheme=${this.selectedScheme}
+            @selected-scheme-changed=${this.handleSelectedSchemeValueChanged}
+          ></gr-download-commands>
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderDescription() {
+    return html`
+      <h3 id="Description" class="heading-3">Description</h3>
+      <fieldset>
+        <gr-textarea
+          id="descriptionInput"
+          class="description"
+          autocomplete="on"
+          placeholder="&lt;Insert repo description here&gt;"
+          rows="4"
+          monospace
+          ?disabled=${this.readOnly}
+          .text=${this.repoConfig?.description}
+          @text-changed=${this.handleDescriptionTextChanged}
+        >
+      </fieldset>
+    `;
+  }
+
+  private renderRepoOptions() {
+    return html`
+      <h3 id="Options" class="heading-3">Repository Options</h3>
+      <fieldset id="options">
+        ${this.renderState()} ${this.renderSubmitType()}
+        ${this.renderContentMerges()} ${this.renderNewChange()}
+        ${this.renderChangeId()} ${this.renderEnableSignedPush()}
+        ${this.renderRequireSignedPush()} ${this.renderRejectImplicitMerges()}
+        ${this.renderUnRegisteredCc()} ${this.renderPrivateByDefault()}
+        ${this.renderWorkInProgressByDefault()} ${this.renderMaxGitObjectSize()}
+        ${this.renderMatchAuthoredDateWithCommitterDate()}
+        ${this.renderRejectEmptyCommit()}
+      </fieldset>
+      <h3 id="Options" class="heading-3">Contributor Agreements</h3>
+      <fieldset id="agreements">
+        ${this.renderContributorAgreement()} ${this.renderUseSignedOffBy()}
+      </fieldset>
+    `;
+  }
+
+  private renderState() {
+    return html`
+      <section>
+        <span class="title">State</span>
+        <span class="value">
+          <gr-select
+            id="stateSelect"
+            .bindValue=${this.repoConfig?.state}
+            @bind-value-changed=${this.handleStateSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.states.map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderSubmitType() {
+    return html`
+      <section>
+        <span class="title">Submit type</span>
+        <span class="value">
+          <gr-select
+            id="submitTypeSelect"
+            .bindValue=${this.repoConfig?.submit_type}
+            @bind-value-changed=${this.handleSubmitTypeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatSubmitTypeSelect(this.repoConfig).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderContentMerges() {
+    return html`
+      <section>
+        <span class="title">Allow content merges</span>
+        <span class="value">
+          <gr-select
+            id="contentMergeSelect"
+            .bindValue=${this.repoConfig?.use_content_merge?.configured_value}
+            @bind-value-changed=${this.handleContentMergeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_content_merge
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderNewChange() {
+    return html`
+      <section>
+        <span class="title">
+          Create a new change for every commit not in the target branch
+        </span>
+        <span class="value">
+          <gr-select
+            id="newChangeSelect"
+            .bindValue=${this.repoConfig
+              ?.create_new_change_for_all_not_in_target?.configured_value}
+            @bind-value-changed=${this.handleNewChangeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.create_new_change_for_all_not_in_target
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderChangeId() {
+    return html`
+      <section>
+        <span class="title">Require Change-Id in commit message</span>
+        <span class="value">
+          <gr-select
+            id="requireChangeIdSelect"
+            .bindValue=${this.repoConfig?.require_change_id?.configured_value}
+            @bind-value-changed=${this
+              .handleRequireChangeIdSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.require_change_id
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEnableSignedPush() {
+    return html`
+      <section
+        id="enableSignedPushSettings"
+        class="repositorySettings ${this.repoConfig?.enable_signed_push
+          ? 'showConfig'
+          : ''}"
+      >
+        <span class="title">Enable signed push</span>
+        <span class="value">
+          <gr-select
+            id="enableSignedPush"
+            .bindValue=${this.repoConfig?.enable_signed_push?.configured_value}
+            @bind-value-changed=${this.handleEnableSignedPushBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.enable_signed_push
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRequireSignedPush() {
+    return html`
+      <section
+        id="requireSignedPushSettings"
+        class="repositorySettings ${this.repoConfig?.require_signed_push
+          ? 'showConfig'
+          : ''}"
+      >
+        <span class="title">Require signed push</span>
+        <span class="value">
+          <gr-select
+            id="requireSignedPush"
+            .bindValue=${this.repoConfig?.require_signed_push?.configured_value}
+            @bind-value-changed=${this.handleRequireSignedPushBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.require_signed_push
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRejectImplicitMerges() {
+    return html`
+      <section>
+        <span class="title">
+          Reject implicit merges when changes are pushed for review</span
+        >
+        <span class="value">
+          <gr-select
+            id="rejectImplicitMergesSelect"
+            .bindValue=${this.repoConfig?.reject_implicit_merges
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleRejectImplicitMergeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.reject_implicit_merges
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderUnRegisteredCc() {
+    return html`
+      <section>
+        <span class="title">
+          Enable adding unregistered users as reviewers and CCs on changes</span
+        >
+        <span class="value">
+          <gr-select
+            id="unRegisteredCcSelect"
+            .bindValue=${this.repoConfig?.enable_reviewer_by_email
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleUnRegisteredCcSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.enable_reviewer_by_email
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPrivateByDefault() {
+    return html`
+      <section>
+        <span class="title"> Set all new changes private by default</span>
+        <span class="value">
+          <gr-select
+            id="setAllnewChangesPrivateByDefaultSelect"
+            .bindValue=${this.repoConfig?.private_by_default?.configured_value}
+            @bind-value-changed=${this
+              .handleSetAllNewChangesPrivateByDefaultSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.private_by_default
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderWorkInProgressByDefault() {
+    return html`
+      <section>
+        <span class="title">
+          Set new changes to "work in progress" by default</span
+        >
+        <span class="value">
+          <gr-select
+            id="setAllNewChangesWorkInProgressByDefaultSelect"
+            .bindValue=${this.repoConfig?.work_in_progress_by_default
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleSetAllNewChangesWorkInProgressByDefaultSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.work_in_progress_by_default
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderMaxGitObjectSize() {
+    return html`
+      <section>
+        <span class="title">Maximum Git object size limit</span>
+        <span class="value">
+          <iron-input
+            id="maxGitObjSizeIronInput"
+            .bindValue=${this.repoConfig?.max_object_size_limit
+              ?.configured_value}
+            type="text"
+            ?disabled=${this.readOnly}
+            @bind-value-changed=${this.handleMaxGitObjSizeBindValueChanged}
+          >
+            <input
+              id="maxGitObjSizeInput"
+              type="text"
+              ?disabled=${this.readOnly}
+            />
+          </iron-input>
+          ${this.repoConfig?.max_object_size_limit?.value
+            ? `effective: ${this.repoConfig.max_object_size_limit.value} bytes`
+            : ''}
+        </span>
+      </section>
+    `;
+  }
+
+  private renderMatchAuthoredDateWithCommitterDate() {
+    return html`
+      <section>
+        <span class="title"
+          >Match authored date with committer date upon submit</span
+        >
+        <span class="value">
+          <gr-select
+            id="matchAuthoredDateWithCommitterDateSelect"
+            .bindValue=${this.repoConfig?.match_author_to_committer_date
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleMatchAuthoredDateWithCommitterDateSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.match_author_to_committer_date
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRejectEmptyCommit() {
+    return html`
+      <section>
+        <span class="title">Reject empty commit upon submit</span>
+        <span class="value">
+          <gr-select
+            id="rejectEmptyCommitSelect"
+            .bindValue=${this.repoConfig?.reject_empty_commit?.configured_value}
+            @bind-value-changed=${this
+              .handleRejectEmptyCommitSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.reject_empty_commit
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderContributorAgreement() {
+    return html`
+      <section>
+        <span class="title">
+          Require a valid contributor agreement to upload</span
+        >
+        <span class="value">
+          <gr-select
+            id="contributorAgreementSelect"
+            .bindValue=${this.repoConfig?.use_contributor_agreements
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleUseContributorAgreementsBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_contributor_agreements
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderUseSignedOffBy() {
+    return html`
+      <section>
+        <span class="title">Require Signed-off-by in commit message</span>
+        <span class="value">
+          <gr-select
+            id="useSignedOffBySelect"
+            .bindValue=${this.repoConfig?.use_signed_off_by?.configured_value}
+            @bind-value-changed=${this
+              .handleUseSignedOffBySelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_signed_off_by
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPluginConfig() {
+    const pluginData = this.computePluginData();
+    return html` <div
+      class="pluginConfig ${!pluginData || !pluginData.length ? 'hide' : ''}"
+      @plugin-config-changed=${this.handlePluginConfigChanged}
+    >
+      <h3 class="heading-3">Plugins</h3>
+      ${pluginData.map(
+        item => html`
+          <gr-repo-plugin-config .pluginData=${item}></gr-repo-plugin-config>
+        `
+      )}
+    </div>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('schemesObj')) {
+      this.computeSchemesAndDefault();
+    }
+  }
+
+  /* private but used in test */
+  computePluginData() {
+    if (!this.repoConfig || !this.repoConfig.plugin_config) return [];
+    const pluginConfig = this.repoConfig.plugin_config;
     return Object.keys(pluginConfig).map(name => {
       return {name, config: pluginConfig[name]};
     });
   }
 
-  _loadRepo() {
-    if (!this.repo) {
-      return Promise.resolve();
-    }
+  /* private but used in test */
+  async loadRepo() {
+    if (!this.repo) return Promise.resolve();
 
     const promises = [];
 
@@ -175,11 +756,16 @@
     };
 
     promises.push(
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
+      this.restApiService.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           const repo = this.repo;
           if (!repo) throw new Error('undefined repo');
+          this.restApiService.getPreferences().then(prefs => {
+            if (prefs?.download_scheme) {
+              // Note (issue 5180): normalize the download scheme with lower-case.
+              this.selectedScheme = prefs.download_scheme.toLowerCase();
+            }
+          });
           this.restApiService.getRepo(repo).then(repo => {
             if (!repo?.web_links) return;
             this.weblinks = repo.web_links;
@@ -190,71 +776,60 @@
             }
 
             // If the user is not an owner, is_owner is not a property.
-            this._readOnly = !access[repo]?.is_owner;
+            this.readOnly = !access[repo]?.is_owner;
           });
         }
       })
     );
 
-    promises.push(
-      this.restApiService.getProjectConfig(this.repo, errFn).then(config => {
-        if (!config) {
-          return;
-        }
+    const repoConfigHelper = async () => {
+      const config = await this.restApiService.getProjectConfig(
+        this.repo as RepoName,
+        errFn
+      );
+      if (!config) return;
 
-        if (config.default_submit_type) {
-          // The gr-select is bound to submit_type, which needs to be the
-          // *configured* submit type. When default_submit_type is
-          // present, the server reports the *effective* submit type in
-          // submit_type, so we need to overwrite it before storing the
-          // config in this.
-          config.submit_type = config.default_submit_type.configured_value;
-        }
-        if (!config.state) {
-          config.state = STATES.active.value;
-        }
-        this._repoConfig = config;
-        this._loading = false;
-      })
-    );
-
-    promises.push(
-      this.restApiService.getConfig().then(config => {
-        if (!config) {
-          return;
-        }
-
-        this._schemesObj = config.download.schemes;
-      })
-    );
-
-    return Promise.all(promises);
-  }
-
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  _computeHideClass(arr?: PluginData[] | string[]) {
-    return !arr || !arr.length ? 'hide' : '';
-  }
-
-  _loggedInChanged(_loggedIn?: boolean) {
-    if (!_loggedIn) {
-      return;
-    }
-    this.restApiService.getPreferences().then(prefs => {
-      if (prefs?.download_scheme) {
-        // Note (issue 5180): normalize the download scheme with lower-case.
-        this._selectedScheme = prefs.download_scheme.toLowerCase();
+      if (config.default_submit_type) {
+        // The gr-select is bound to submit_type, which needs to be the
+        // *configured* submit type. When default_submit_type is
+        // present, the server reports the *effective* submit type in
+        // submit_type, so we need to overwrite it before storing the
+        // config in this.
+        config.submit_type = config.default_submit_type.configured_value;
       }
-    });
+      if (!config.state) {
+        config.state = STATES.active.value as ProjectState;
+      }
+      // To properly check if the config has changed we need it to be a string
+      // as it's converted to a string in the input.
+      if (config.description === undefined) {
+        config.description = '';
+      }
+      // To properly check if the config has changed we need it to be a string
+      // as it's converted to a string in the input.
+      if (config.max_object_size_limit.configured_value === undefined) {
+        config.max_object_size_limit.configured_value = '';
+      }
+      this.repoConfig = config;
+      this.originalConfig = deepClone(config);
+      this.loading = false;
+    };
+    promises.push(repoConfigHelper());
+
+    const configHelper = async () => {
+      const config = await this.restApiService.getConfig();
+      if (!config) return;
+
+      this.schemesObj = config.download.schemes;
+    };
+    promises.push(configHelper());
+
+    await Promise.all(promises);
   }
 
-  _formatBooleanSelect(item: InheritedBooleanInfo) {
-    if (!item) {
-      return;
-    }
+  /* private but used in test */
+  formatBooleanSelect(item?: InheritedBooleanInfo) {
+    if (!item) return [];
     let inheritLabel = 'Inherit';
     if (!(item.inherited_value === undefined)) {
       inheritLabel = `Inherit (${item.inherited_value})`;
@@ -275,12 +850,10 @@
     ];
   }
 
-  _formatSubmitTypeSelect(projectConfig: ConfigInfo) {
-    if (!projectConfig) {
-      return;
-    }
+  private formatSubmitTypeSelect(repoConfig?: ConfigInfo) {
+    if (!repoConfig) return [];
     const allValues = Object.values(SUBMIT_TYPES);
-    const type = projectConfig.default_submit_type;
+    const type = repoConfig.default_submit_type;
     if (!type) {
       // Server is too old to report default_submit_type, so assume INHERIT
       // is not a valid value.
@@ -306,15 +879,9 @@
     ];
   }
 
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  _formatRepoConfigForSave(repoConfig: ConfigInfo): ConfigInput {
+  /* private but used in test */
+  formatRepoConfigForSave(repoConfig?: ConfigInfo): ConfigInput {
+    if (!repoConfig) return {};
     const configInputObj: ConfigInput = {};
     for (const configKey of Object.keys(repoConfig)) {
       const key = configKey as keyof ConfigInfo;
@@ -329,7 +896,7 @@
       } else if (typeof repoConfig[key] === 'object') {
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         const repoConfigObj: any = repoConfig[key];
-        if (repoConfigObj.configured_value) {
+        if (repoConfigObj.configured_value !== undefined) {
           configInputObj[key as keyof ConfigInput] =
             repoConfigObj.configured_value;
         }
@@ -341,56 +908,173 @@
     return configInputObj;
   }
 
-  _handleSaveRepoConfig() {
-    if (!this._repoConfig || !this.repo)
+  /* private but used in test */
+  async handleSaveRepoConfig() {
+    if (!this.repoConfig || !this.repo)
       return Promise.reject(new Error('undefined repoConfig or repo'));
-    return this.restApiService
-      .saveRepoConfig(
-        this.repo,
-        this._formatRepoConfigForSave(this._repoConfig)
+    await this.restApiService.saveRepoConfig(
+      this.repo,
+      this.formatRepoConfigForSave(this.repoConfig)
+    );
+    this.originalConfig = deepClone(this.repoConfig);
+    this.pluginConfigChanged = false;
+    return;
+  }
+
+  private isEdited(
+    original?: InheritedBooleanInfo | MaxObjectSizeLimitInfo,
+    repo?: InheritedBooleanInfo | MaxObjectSizeLimitInfo
+  ) {
+    return original?.configured_value !== repo?.configured_value;
+  }
+
+  private hasConfigChanged() {
+    const {repoConfig, originalConfig} = this;
+
+    if (!repoConfig || !originalConfig) return false;
+
+    if (originalConfig.description !== repoConfig.description) {
+      return true;
+    }
+    if (originalConfig.state !== repoConfig.state) {
+      return true;
+    }
+    if (originalConfig.submit_type !== repoConfig.submit_type) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_content_merge,
+        repoConfig.use_content_merge
       )
-      .then(() => {
-        this._configChanged = false;
-      });
-  }
-
-  @observe('_repoConfig.*')
-  _handleConfigChanged() {
-    if (this._isLoading()) {
-      return;
+    ) {
+      return true;
     }
-    this._configChanged = true;
-  }
-
-  _computeButtonDisabled(readOnly: boolean, configChanged: boolean) {
-    return readOnly || !configChanged;
-  }
-
-  _computeHeaderClass(configChanged: boolean) {
-    return configChanged ? 'edited' : '';
-  }
-
-  _computeSchemes(schemesDefault: string[], schemesObj?: SchemesInfoMap) {
-    return !schemesObj ? schemesDefault : Object.keys(schemesObj);
-  }
-
-  _schemesChanged(schemes: string[]) {
-    if (schemes.length === 0) {
-      return;
+    if (
+      this.isEdited(
+        originalConfig.create_new_change_for_all_not_in_target,
+        repoConfig.create_new_change_for_all_not_in_target
+      )
+    ) {
+      return true;
     }
-    if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
-      this._selectedScheme = schemes.sort()[0];
+    if (
+      this.isEdited(
+        originalConfig.require_change_id,
+        repoConfig.require_change_id
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.enable_signed_push,
+        repoConfig.enable_signed_push
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.require_signed_push,
+        repoConfig.require_signed_push
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.reject_implicit_merges,
+        repoConfig.reject_implicit_merges
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.enable_reviewer_by_email,
+        repoConfig.enable_reviewer_by_email
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.private_by_default,
+        repoConfig.private_by_default
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.work_in_progress_by_default,
+        repoConfig.work_in_progress_by_default
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.max_object_size_limit,
+        repoConfig.max_object_size_limit
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.match_author_to_committer_date,
+        repoConfig.match_author_to_committer_date
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.reject_empty_commit,
+        repoConfig.reject_empty_commit
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_contributor_agreements,
+        repoConfig.use_contributor_agreements
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_signed_off_by,
+        repoConfig.use_signed_off_by
+      )
+    ) {
+      return true;
+    }
+
+    return this.pluginConfigChanged;
+  }
+
+  private computeSchemesAndDefault() {
+    this.schemes = !this.schemesObj ? [] : Object.keys(this.schemesObj);
+    if (this.schemes.length > 0) {
+      if (!this.selectedScheme || !this.schemes.includes(this.selectedScheme)) {
+        this.selectedScheme = this.schemes.sort()[0];
+      }
     }
   }
 
-  _computeCommands(
+  private computeCommands(
     repo?: RepoName,
     schemesObj?: SchemesInfoMap,
-    _selectedScheme?: string
+    selectedScheme?: string
   ) {
-    if (!schemesObj || !repo || !_selectedScheme) return [];
-    if (!hasOwnProperty(schemesObj, _selectedScheme)) return [];
-    const commandObj = schemesObj[_selectedScheme].clone_commands;
+    if (!schemesObj || !repo || !selectedScheme) return [];
+    if (!hasOwnProperty(schemesObj, selectedScheme)) return [];
+    const commandObj = schemesObj[selectedScheme].clone_commands;
     const commands = [];
     for (const [title, command] of Object.entries(commandObj)) {
       commands.push({
@@ -406,36 +1090,171 @@
     return commands;
   }
 
-  _computeRepositoriesClass(config: InheritedBooleanInfo) {
-    return config ? 'showConfig' : '';
+  private computeChangesUrl(name?: RepoName) {
+    if (!name) return '';
+    return GerritNav.getUrlForProjectChanges(name as RepoName);
   }
 
-  _computeChangesUrl(name: RepoName) {
-    return GerritNav.getUrlForProjectChanges(name);
-  }
-
-  _computeBrowseUrl(weblinks: WebLinkInfo[]) {
-    return weblinks?.[0]?.url;
-  }
-
-  _handlePluginConfigChanged({
-    detail: {name, config, notifyPath},
+  /* private but used in test */
+  handlePluginConfigChanged({
+    detail: {name, config},
   }: {
     detail: {
       name: string;
       config: PluginParameterToConfigParameterInfoMap;
-      notifyPath: string;
     };
   }) {
-    if (this._repoConfig?.plugin_config) {
-      this._repoConfig.plugin_config[name] = config;
-      this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+    if (this.repoConfig?.plugin_config) {
+      this.repoConfig.plugin_config[name] = config;
+      this.pluginConfigChanged = true;
+      this.requestUpdate();
     }
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-repo': GrRepo;
+  private handleSelectedSchemeValueChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.selectedScheme = e.detail.value;
+  }
+
+  private handleDescriptionTextChanged(e: CustomEvent) {
+    if (!this.repoConfig || this.loading) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      description: e.detail.value,
+    };
+    this.requestUpdate();
+  }
+
+  private handleStateSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig || this.loading) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      state: e.detail.value as ProjectState,
+    };
+    this.requestUpdate();
+  }
+
+  private handleSubmitTypeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig || this.loading) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      submit_type: e.detail.value as SubmitType,
+    };
+    this.requestUpdate();
+  }
+
+  private handleContentMergeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.use_content_merge || this.loading) return;
+    this.repoConfig.use_content_merge.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleNewChangeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.repoConfig?.create_new_change_for_all_not_in_target ||
+      this.loading
+    )
+      return;
+    this.repoConfig.create_new_change_for_all_not_in_target.configured_value = e
+      .detail.value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRequireChangeIdSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.require_change_id || this.loading) return;
+    this.repoConfig.require_change_id.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleEnableSignedPushBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.enable_signed_push || this.loading) return;
+    this.repoConfig.enable_signed_push.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRequireSignedPushBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.require_signed_push || this.loading) return;
+    this.repoConfig.require_signed_push.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRejectImplicitMergeSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.reject_implicit_merges || this.loading) return;
+    this.repoConfig.reject_implicit_merges.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUnRegisteredCcSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.enable_reviewer_by_email || this.loading) return;
+    this.repoConfig.enable_reviewer_by_email.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleSetAllNewChangesPrivateByDefaultSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.private_by_default || this.loading) return;
+    this.repoConfig.private_by_default.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleSetAllNewChangesWorkInProgressByDefaultSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.work_in_progress_by_default || this.loading) return;
+    this.repoConfig.work_in_progress_by_default.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleMaxGitObjSizeBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.max_object_size_limit || this.loading) return;
+    this.repoConfig.max_object_size_limit.value = e.detail.value;
+    this.repoConfig.max_object_size_limit.configured_value = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleMatchAuthoredDateWithCommitterDateSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.match_author_to_committer_date || this.loading)
+      return;
+    this.repoConfig.match_author_to_committer_date.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRejectEmptyCommitSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.reject_empty_commit || this.loading) return;
+    this.repoConfig.reject_empty_commit.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUseContributorAgreementsBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.use_contributor_agreements || this.loading) return;
+    this.repoConfig.use_contributor_agreements.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUseSignedOffBySelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.use_signed_off_by || this.loading) return;
+    this.repoConfig.use_signed_off_by.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
deleted file mode 100644
index 71abec0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
+++ /dev/null
@@ -1,449 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    .info {
-      margin-bottom: var(--spacing-xl);
-    }
-    h2.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-    .loading,
-    .hide {
-      display: none;
-    }
-    #loading.loading {
-      display: block;
-    }
-    #loading:not(.loading) {
-      display: none;
-    }
-    #options .repositorySettings {
-      display: none;
-    }
-    #options .repositorySettings.showConfig {
-      display: block;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="main gr-form-styles read-only">
-    <style include="shared-styles">
-      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-    </style>
-    <div class="info">
-      <h1 id="Title" class="heading-1">[[repo]]</h1>
-      <hr />
-      <div>
-        <a href$="[[_computeBrowseUrl(weblinks)]]"
-          ><gr-button link disabled="[[!_computeBrowseUrl(weblinks)]]"
-            >Browse</gr-button
-          ></a
-        ><a href$="[[_computeChangesUrl(repo)]]"
-          ><gr-button link>View Changes</gr-button></a
-        >
-      </div>
-    </div>
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
-        <h2 id="download" class="heading-2">Download</h2>
-        <fieldset>
-          <gr-download-commands
-            id="downloadCommands"
-            commands="[[_computeCommands(repo, _schemesObj, _selectedScheme)]]"
-            schemes="[[_schemes]]"
-            selected-scheme="{{_selectedScheme}}"
-          ></gr-download-commands>
-        </fieldset>
-      </div>
-      <h2
-        id="configurations"
-        class$="heading-2 [[_computeHeaderClass(_configChanged)]]"
-      >
-        Configurations
-      </h2>
-      <div id="form">
-        <fieldset>
-          <h3 id="Description" class="heading-3">Description</h3>
-          <fieldset>
-            <iron-autogrow-textarea
-              id="descriptionInput"
-              class="description"
-              autocomplete="on"
-              placeholder="<Insert repo description here>"
-              bind-value="{{_repoConfig.description}}"
-              disabled$="[[_readOnly]]"
-            ></iron-autogrow-textarea>
-          </fieldset>
-          <h3 id="Options" class="heading-3">Repository Options</h3>
-          <fieldset id="options">
-            <section>
-              <span class="title">State</span>
-              <span class="value">
-                <gr-select id="stateSelect" bind-value="{{_repoConfig.state}}">
-                  <select disabled$="[[_readOnly]]">
-                    <template is="dom-repeat" items="[[_states]]">
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Submit type</span>
-              <span class="value">
-                <gr-select
-                  id="submitTypeSelect"
-                  bind-value="{{_repoConfig.submit_type}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatSubmitTypeSelect(_repoConfig)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Allow content merges</span>
-              <span class="value">
-                <gr-select
-                  id="contentMergeSelect"
-                  bind-value="{{_repoConfig.use_content_merge.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_content_merge)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Create a new change for every commit not in the target branch
-              </span>
-              <span class="value">
-                <gr-select
-                  id="newChangeSelect"
-                  bind-value="{{_repoConfig.create_new_change_for_all_not_in_target.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.create_new_change_for_all_not_in_target)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Require Change-Id in commit message</span>
-              <span class="value">
-                <gr-select
-                  id="requireChangeIdSelect"
-                  bind-value="{{_repoConfig.require_change_id.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.require_change_id)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section
-              id="enableSignedPushSettings"
-              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.enable_signed_push)]]"
-            >
-              <span class="title">Enable signed push</span>
-              <span class="value">
-                <gr-select
-                  id="enableSignedPush"
-                  bind-value="{{_repoConfig.enable_signed_push.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.enable_signed_push)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section
-              id="requireSignedPushSettings"
-              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.require_signed_push)]]"
-            >
-              <span class="title">Require signed push</span>
-              <span class="value">
-                <gr-select
-                  id="requireSignedPush"
-                  bind-value="{{_repoConfig.require_signed_push.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.require_signed_push)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Reject implicit merges when changes are pushed for review</span
-              >
-              <span class="value">
-                <gr-select
-                  id="rejectImplicitMergesSelect"
-                  bind-value="{{_repoConfig.reject_implicit_merges.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.reject_implicit_merges)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Enable adding unregistered users as reviewers and CCs on
-                changes</span
-              >
-              <span class="value">
-                <gr-select
-                  id="unRegisteredCcSelect"
-                  bind-value="{{_repoConfig.enable_reviewer_by_email.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.enable_reviewer_by_email)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title"> Set all new changes private by default</span>
-              <span class="value">
-                <gr-select
-                  id="setAllnewChangesPrivateByDefaultSelect"
-                  bind-value="{{_repoConfig.private_by_default.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.private_by_default)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Set new changes to "work in progress" by default</span
-              >
-              <span class="value">
-                <gr-select
-                  id="setAllNewChangesWorkInProgressByDefaultSelect"
-                  bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Maximum Git object size limit</span>
-              <span class="value">
-                <iron-input
-                  id="maxGitObjSizeIronInput"
-                  bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                  type="text"
-                  disabled$="[[_readOnly]]"
-                >
-                  <input
-                    id="maxGitObjSizeInput"
-                    bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                    is="iron-input"
-                    type="text"
-                    disabled$="[[_readOnly]]"
-                  />
-                </iron-input>
-                <template
-                  is="dom-if"
-                  if="[[_repoConfig.max_object_size_limit.value]]"
-                >
-                  effective: [[_repoConfig.max_object_size_limit.value]] bytes
-                </template>
-              </span>
-            </section>
-            <section>
-              <span class="title"
-                >Match authored date with committer date upon submit</span
-              >
-              <span class="value">
-                <gr-select
-                  id="matchAuthoredDateWithCommitterDateSelect"
-                  bind-value="{{_repoConfig.match_author_to_committer_date.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.match_author_to_committer_date)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Reject empty commit upon submit</span>
-              <span class="value">
-                <gr-select
-                  id="rejectEmptyCommitSelect"
-                  bind-value="{{_repoConfig.reject_empty_commit.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.reject_empty_commit)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-          </fieldset>
-          <h3 id="Options" class="heading-3">Contributor Agreements</h3>
-          <fieldset id="agreements">
-            <section>
-              <span class="title">
-                Require a valid contributor agreement to upload</span
-              >
-              <span class="value">
-                <gr-select
-                  id="contributorAgreementSelect"
-                  bind-value="{{_repoConfig.use_contributor_agreements.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_contributor_agreements)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Require Signed-off-by in commit message</span>
-              <span class="value">
-                <gr-select
-                  id="useSignedOffBySelect"
-                  bind-value="{{_repoConfig.use_signed_off_by.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_signed_off_by)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-          </fieldset>
-          <div
-            class$="pluginConfig [[_computeHideClass(_pluginData)]]"
-            on-plugin-config-changed="_handlePluginConfigChanged"
-          >
-            <h3 class="heading-3">Plugins</h3>
-            <template is="dom-repeat" items="[[_pluginData]]" as="data">
-              <gr-repo-plugin-config
-                plugin-data="[[data]]"
-              ></gr-repo-plugin-config>
-            </template>
-          </div>
-          <gr-button
-            on-click="_handleSaveRepoConfig"
-            disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]"
-            >Save changes</gr-button
-          >
-        </fieldset>
-        <gr-endpoint-decorator name="repo-config">
-          <gr-endpoint-param
-            name="repoName"
-            value="[[repo]]"
-          ></gr-endpoint-param>
-          <gr-endpoint-param
-            name="readOnly"
-            value="[[_readOnly]]"
-          ></gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </div>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
deleted file mode 100644
index 89ad86e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
+++ /dev/null
@@ -1,358 +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 '../../../test/common-test-setup-karma.js';
-import './gr-repo.js';
-import {mockPromise} from '../../../test/test-utils.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-repo');
-
-suite('gr-repo tests', () => {
-  let element;
-  let loggedInStub;
-  let repoStub;
-  const repoConf = {
-    description: 'Access inherited by all other projects.',
-    use_contributor_agreements: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    use_content_merge: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    use_signed_off_by: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    create_new_change_for_all_not_in_target: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    require_change_id: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    enable_signed_push: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    require_signed_push: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    reject_implicit_merges: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    private_by_default: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    match_author_to_committer_date: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    reject_empty_commit: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    enable_reviewer_by_email: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    max_object_size_limit: {},
-    submit_type: 'MERGE_IF_NECESSARY',
-    default_submit_type: {
-      value: 'MERGE_IF_NECESSARY',
-      configured_value: 'INHERIT',
-      inherited_value: 'MERGE_IF_NECESSARY',
-    },
-  };
-
-  const REPO = 'test-repo';
-  const SCHEMES = {http: {}, repo: {}, ssh: {}};
-
-  function getFormFields() {
-    const selects = Array.from(
-        element.root.querySelectorAll('select'));
-    const textareas = Array.from(
-        element.root.querySelectorAll('iron-autogrow-textarea'));
-    const inputs = Array.from(
-        element.root.querySelectorAll('input'));
-    return inputs.concat(textareas).concat(selects);
-  }
-
-  setup(() => {
-    loggedInStub = stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getConfig').returns(Promise.resolve({download: {}}));
-    repoStub =
-        stubRestApi('getProjectConfig').returns(Promise.resolve(repoConf));
-    element = basicFixture.instantiate();
-  });
-
-  test('_computePluginData', () => {
-    assert.deepEqual(element._computePluginData(), []);
-    assert.deepEqual(element._computePluginData({}), []);
-    assert.deepEqual(element._computePluginData({base: {}}), []);
-    assert.deepEqual(element._computePluginData({base: {plugin: 'data'}}),
-        [{name: 'plugin', config: 'data'}]);
-  });
-
-  test('_handlePluginConfigChanged', () => {
-    const notifyStub = sinon.stub(element, 'notifyPath');
-    element._repoConfig = {plugin_config: {}};
-    element._handlePluginConfigChanged({detail: {
-      name: 'test',
-      config: 'data',
-      notifyPath: 'path',
-    }});
-    flush();
-
-    assert.equal(element._repoConfig.plugin_config.test, 'data');
-    assert.equal(notifyStub.lastCall.args[0],
-        '_repoConfig.plugin_config.path');
-  });
-
-  test('loading displays before repo config is loaded', () => {
-    assert.isTrue(element.$.loading.classList.contains('loading'));
-    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-    assert.isTrue(getComputedStyle(element.$.loadedContent)
-        .display === 'none');
-  });
-
-  test('download commands visibility', () => {
-    element._loading = false;
-    flush();
-    assert.isTrue(element.$.downloadContent.classList.contains('hide'));
-    assert.isTrue(getComputedStyle(element.$.downloadContent)
-        .display == 'none');
-    element._schemesObj = SCHEMES;
-    flush();
-    assert.isFalse(element.$.downloadContent.classList.contains('hide'));
-    assert.isFalse(getComputedStyle(element.$.downloadContent)
-        .display == 'none');
-  });
-
-  test('form defaults to read only', () => {
-    assert.isTrue(element._readOnly);
-  });
-
-  test('form defaults to read only when not logged in', async () => {
-    element.repo = REPO;
-    await element._loadRepo();
-    assert.isTrue(element._readOnly);
-  });
-
-  test('form defaults to read only when logged in and not admin', async () => {
-    element.repo = REPO;
-    stubRestApi('getRepoAccess')
-        .callsFake(() => Promise.resolve({'test-repo': {}}));
-    await element._loadRepo();
-    assert.isTrue(element._readOnly);
-  });
-
-  test('all form elements are disabled when not admin', async () => {
-    element.repo = REPO;
-    await element._loadRepo();
-    flush();
-    const formFields = getFormFields();
-    for (const field of formFields) {
-      assert.isTrue(field.hasAttribute('disabled'));
-    }
-  });
-
-  test('_formatBooleanSelect', () => {
-    let item = {inherited_value: true};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit (true)',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-
-    item = {inherited_value: false};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit (false)',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-
-    // For items without inherited values
-    item = {};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-  });
-
-  test('fires page-error', async () => {
-    repoStub.restore();
-
-    element.repo = 'test';
-
-    const pageErrorFired = mockPromise();
-    const response = {status: 404};
-    stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
-      errFn(response);
-      return Promise.resolve(undefined);
-    });
-    addListenerForTest(document, 'page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      pageErrorFired.resolve();
-    });
-
-    element._loadRepo();
-    await pageErrorFired;
-  });
-
-  suite('admin', () => {
-    setup(() => {
-      element.repo = REPO;
-      loggedInStub.returns(Promise.resolve(true));
-      stubRestApi('getRepoAccess')
-          .returns(Promise.resolve({'test-repo': {is_owner: true}}));
-    });
-
-    test('all form elements are enabled', async () => {
-      await element._loadRepo();
-      await flush();
-      const formFields = getFormFields();
-      for (const field of formFields) {
-        assert.isFalse(field.hasAttribute('disabled'));
-      }
-      assert.isFalse(element._loading);
-    });
-
-    test('state gets set correctly', async () => {
-      await element._loadRepo();
-      assert.equal(element._repoConfig.state, 'ACTIVE');
-      assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
-    });
-
-    test('inherited submit type value is calculated correctly', async () => {
-      await element._loadRepo();
-      const sel = element.$.submitTypeSelect;
-      assert.equal(sel.bindValue, 'INHERIT');
-      assert.equal(
-          sel.nativeSelect.options[0].text,
-          'Inherit (Merge if necessary)'
-      );
-    });
-
-    test('fields update and save correctly', async () => {
-      const configInputObj = {
-        description: 'new description',
-        use_contributor_agreements: 'TRUE',
-        use_content_merge: 'TRUE',
-        use_signed_off_by: 'TRUE',
-        create_new_change_for_all_not_in_target: 'TRUE',
-        require_change_id: 'TRUE',
-        enable_signed_push: 'TRUE',
-        require_signed_push: 'TRUE',
-        reject_implicit_merges: 'TRUE',
-        private_by_default: 'TRUE',
-        match_author_to_committer_date: 'TRUE',
-        reject_empty_commit: 'TRUE',
-        max_object_size_limit: 10,
-        submit_type: 'FAST_FORWARD_ONLY',
-        state: 'READ_ONLY',
-        enable_reviewer_by_email: 'TRUE',
-      };
-
-      const saveStub = stubRestApi('saveRepoConfig')
-          .callsFake(() => Promise.resolve({}));
-
-      const button = element.root.querySelectorAll('gr-button')[2];
-
-      await element._loadRepo();
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-      element.$.descriptionInput.bindValue = configInputObj.description;
-      element.$.stateSelect.bindValue = configInputObj.state;
-      element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
-      element.$.contentMergeSelect.bindValue =
-          configInputObj.use_content_merge;
-      element.$.newChangeSelect.bindValue =
-          configInputObj.create_new_change_for_all_not_in_target;
-      element.$.requireChangeIdSelect.bindValue =
-          configInputObj.require_change_id;
-      element.$.enableSignedPush.bindValue =
-          configInputObj.enable_signed_push;
-      element.$.requireSignedPush.bindValue =
-          configInputObj.require_signed_push;
-      element.$.rejectImplicitMergesSelect.bindValue =
-          configInputObj.reject_implicit_merges;
-      element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
-          configInputObj.private_by_default;
-      element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
-          configInputObj.match_author_to_committer_date;
-      const inputElement = PolymerElement ?
-        element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
-      inputElement.bindValue = configInputObj.max_object_size_limit;
-      element.$.contributorAgreementSelect.bindValue =
-          configInputObj.use_contributor_agreements;
-      element.$.useSignedOffBySelect.bindValue =
-          configInputObj.use_signed_off_by;
-      element.$.rejectEmptyCommitSelect.bindValue =
-          configInputObj.reject_empty_commit;
-      element.$.unRegisteredCcSelect.bindValue =
-          configInputObj.enable_reviewer_by_email;
-
-      assert.isFalse(button.hasAttribute('disabled'));
-      assert.isTrue(element.$.configurations.classList.contains('edited'));
-
-      const formattedObj =
-          element._formatRepoConfigForSave(element._repoConfig);
-      assert.deepEqual(formattedObj, configInputObj);
-
-      await element._handleSaveRepoConfig();
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-      assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
-          configInputObj));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
new file mode 100644
index 0000000..82338d3
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -0,0 +1,570 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import './gr-repo';
+import {GrRepo} from './gr-repo';
+import {mockPromise} from '../../../test/test-utils';
+import {
+  addListenerForTest,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  createInheritedBoolean,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {
+  ConfigInfo,
+  GitRef,
+  GroupId,
+  GroupName,
+  InheritedBooleanInfo,
+  MaxObjectSizeLimitInfo,
+  PluginParameterToConfigParameterInfoMap,
+  ProjectAccessGroups,
+  ProjectAccessInfoMap,
+  RepoName,
+} from '../../../types/common';
+import {
+  ConfigParameterInfoType,
+  InheritedBooleanInfoConfiguredValue,
+  ProjectState,
+  SubmitType,
+} from '../../../constants/constants';
+import {
+  createConfig,
+  createDownloadSchemes,
+} from '../../../test/test-data-generators';
+import {PageErrorEvent} from '../../../types/events.js';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {IronInputElement} from '@polymer/iron-input/iron-input';
+
+const basicFixture = fixtureFromElement('gr-repo');
+
+suite('gr-repo tests', () => {
+  let element: GrRepo;
+  let loggedInStub: sinon.SinonStub;
+  let repoStub: sinon.SinonStub;
+
+  const repoConf: ConfigInfo = {
+    description: 'Access inherited by all other projects.',
+    use_contributor_agreements: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    use_content_merge: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    use_signed_off_by: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    create_new_change_for_all_not_in_target: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    require_change_id: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    enable_signed_push: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    require_signed_push: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    reject_implicit_merges: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    match_author_to_committer_date: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    reject_empty_commit: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    enable_reviewer_by_email: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    private_by_default: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    work_in_progress_by_default: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    max_object_size_limit: {},
+    commentlinks: {},
+    submit_type: SubmitType.MERGE_IF_NECESSARY,
+    default_submit_type: {
+      value: SubmitType.MERGE_IF_NECESSARY,
+      configured_value: SubmitType.INHERIT,
+      inherited_value: SubmitType.MERGE_IF_NECESSARY,
+    },
+  };
+
+  const REPO = 'test-repo';
+  const SCHEMES = {
+    ...createDownloadSchemes(),
+    http: {
+      url: 'test',
+      is_auth_required: false,
+      is_auth_supported: false,
+      commands: 'test',
+      clone_commands: {clone: 'test'},
+    },
+    repo: {
+      url: 'test',
+      is_auth_required: false,
+      is_auth_supported: false,
+      commands: 'test',
+      clone_commands: {clone: 'test'},
+    },
+    ssh: {
+      url: 'test',
+      is_auth_required: false,
+      is_auth_supported: false,
+      commands: 'test',
+      clone_commands: {clone: 'test'},
+    },
+  };
+
+  function getFormFields() {
+    const selects = Array.from(queryAll(element, 'select'));
+    const textareas = Array.from(queryAll(element, 'iron-autogrow-textarea'));
+    const inputs = Array.from(queryAll(element, 'input'));
+    return inputs.concat(textareas).concat(selects);
+  }
+
+  setup(async () => {
+    loggedInStub = stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
+    repoStub = stubRestApi('getProjectConfig').returns(
+      Promise.resolve(repoConf)
+    );
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  test('_computePluginData', async () => {
+    element.repoConfig = {
+      ...createConfig(),
+      plugin_config: {},
+    };
+    await element.updateComplete;
+    assert.deepEqual(element.computePluginData(), []);
+
+    element.repoConfig.plugin_config = {
+      'test-plugin': {
+        test: {display_name: 'test plugin', type: 'STRING'},
+      } as PluginParameterToConfigParameterInfoMap,
+    };
+    await element.updateComplete;
+    assert.deepEqual(element.computePluginData(), [
+      {
+        name: 'test-plugin',
+        config: {
+          test: {
+            display_name: 'test plugin',
+            type: 'STRING' as ConfigParameterInfoType,
+          },
+        },
+      },
+    ]);
+  });
+
+  test('handlePluginConfigChanged', async () => {
+    const requestUpdateStub = sinon.stub(element, 'requestUpdate');
+    element.repoConfig = {
+      ...createConfig(),
+      plugin_config: {},
+    };
+    element.handlePluginConfigChanged({
+      detail: {
+        name: 'test',
+        config: {
+          test: {display_name: 'test plugin', type: 'STRING'},
+        } as PluginParameterToConfigParameterInfoMap,
+      },
+    });
+    await element.updateComplete;
+
+    assert.deepEqual(element.repoConfig!.plugin_config!.test, {
+      test: {display_name: 'test plugin', type: 'STRING'},
+    } as PluginParameterToConfigParameterInfoMap);
+    assert.isTrue(requestUpdateStub.called);
+  });
+
+  test('loading displays before repo config is loaded', () => {
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
+        'loading'
+      )
+    );
+    assert.isFalse(
+      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '#loading'))
+        .display === 'none'
+    );
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#loadedContent'
+      ).classList.contains('loading')
+    );
+    assert.isTrue(
+      getComputedStyle(
+        queryAndAssert<HTMLDivElement>(element, '#loadedContent')
+      ).display === 'none'
+    );
+  });
+
+  test('download commands visibility', async () => {
+    element.loading = false;
+    await element.updateComplete;
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#downloadContent'
+      ).classList.contains('hide')
+    );
+    assert.isTrue(
+      getComputedStyle(
+        queryAndAssert<HTMLDivElement>(element, '#downloadContent')
+      ).display === 'none'
+    );
+    element.schemesObj = SCHEMES;
+    await element.updateComplete;
+    assert.isFalse(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#downloadContent'
+      ).classList.contains('hide')
+    );
+    assert.isFalse(
+      getComputedStyle(
+        queryAndAssert<HTMLDivElement>(element, '#downloadContent')
+      ).display === 'none'
+    );
+  });
+
+  test('form defaults to read only', () => {
+    assert.isTrue(element.readOnly);
+  });
+
+  test('form defaults to read only when not logged in', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
+    assert.isTrue(element.readOnly);
+  });
+
+  test('form defaults to read only when logged in and not admin', async () => {
+    element.repo = REPO as RepoName;
+
+    stubRestApi('getRepoAccess').callsFake(() =>
+      Promise.resolve({
+        'test-repo': {
+          revision: 'xxxx',
+          local: {
+            'refs/*': {
+              permissions: {
+                owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
+              },
+            },
+          },
+          owner_of: ['refs/*'] as GitRef[],
+          groups: {
+            xxxx: {
+              id: 'xxxx' as GroupId,
+              url: 'test',
+              name: 'test' as GroupName,
+            },
+          } as ProjectAccessGroups,
+          config_web_links: [{name: 'gitiles', url: 'test'}],
+        },
+      } as ProjectAccessInfoMap)
+    );
+    await element.loadRepo();
+    assert.isTrue(element.readOnly);
+  });
+
+  test('all form elements are disabled when not admin', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
+    await element.updateComplete;
+    const formFields = getFormFields();
+    for (const field of formFields) {
+      assert.isTrue(field.hasAttribute('disabled'));
+    }
+  });
+
+  test('formatBooleanSelect', () => {
+    let item: InheritedBooleanInfo = {
+      ...createInheritedBoolean(true),
+      inherited_value: true,
+    };
+    assert.deepEqual(element.formatBooleanSelect(item), [
+      {
+        label: 'Inherit (true)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      },
+      {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    item = {...createInheritedBoolean(false), inherited_value: false};
+    assert.deepEqual(element.formatBooleanSelect(item), [
+      {
+        label: 'Inherit (false)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      },
+      {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    // For items without inherited values
+    item = createInheritedBoolean(false);
+    assert.deepEqual(element.formatBooleanSelect(item), [
+      {
+        label: 'Inherit',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      },
+      {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+  });
+
+  test('fires page-error', async () => {
+    repoStub.restore();
+
+    element.repo = 'test' as RepoName;
+
+    const pageErrorFired = mockPromise();
+    const response = {...new Response(), status: 404};
+    stubRestApi('getProjectConfig').callsFake((_, errFn) => {
+      if (errFn !== undefined) {
+        errFn(response);
+      }
+      return Promise.resolve(undefined);
+    });
+    addListenerForTest(document, 'page-error', e => {
+      assert.deepEqual((e as PageErrorEvent).detail.response, response);
+      pageErrorFired.resolve();
+    });
+
+    element.loadRepo();
+    await pageErrorFired;
+  });
+
+  suite('admin', () => {
+    setup(() => {
+      element.repo = REPO as RepoName;
+      loggedInStub.returns(Promise.resolve(true));
+      stubRestApi('getRepoAccess').callsFake(() =>
+        Promise.resolve({
+          'test-repo': {
+            revision: 'xxxx',
+            local: {
+              'refs/*': {
+                permissions: {
+                  owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
+                },
+              },
+            },
+            is_owner: true,
+            owner_of: ['refs/*'] as GitRef[],
+            groups: {
+              xxxx: {
+                id: 'xxxx' as GroupId,
+                url: 'test',
+                name: 'test' as GroupName,
+              },
+            } as ProjectAccessGroups,
+            config_web_links: [{name: 'gitiles', url: 'test'}],
+          },
+        } as ProjectAccessInfoMap)
+      );
+    });
+
+    test('all form elements are enabled', async () => {
+      await element.loadRepo();
+      await element.updateComplete;
+      const formFields = getFormFields();
+      for (const field of formFields) {
+        assert.isFalse(field.hasAttribute('disabled'));
+      }
+      assert.isFalse(element.loading);
+    });
+
+    test('state gets set correctly', async () => {
+      await element.loadRepo();
+      assert.equal(element.repoConfig!.state, ProjectState.ACTIVE);
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#stateSelect').bindValue,
+        ProjectState.ACTIVE
+      );
+    });
+
+    test('inherited submit type value is calculated correctly', async () => {
+      await element.loadRepo();
+      const sel = queryAndAssert<GrSelect>(element, '#submitTypeSelect');
+      assert.equal(sel.bindValue, 'INHERIT');
+      assert.equal(
+        sel.nativeSelect.options[0].text,
+        'Inherit (Merge if necessary)'
+      );
+    });
+
+    test('fields update and save correctly', async () => {
+      const configInputObj = {
+        description: 'new description',
+        use_contributor_agreements: InheritedBooleanInfoConfiguredValue.TRUE,
+        use_content_merge: InheritedBooleanInfoConfiguredValue.TRUE,
+        use_signed_off_by: InheritedBooleanInfoConfiguredValue.TRUE,
+        create_new_change_for_all_not_in_target:
+          InheritedBooleanInfoConfiguredValue.TRUE,
+        require_change_id: InheritedBooleanInfoConfiguredValue.TRUE,
+        enable_signed_push: InheritedBooleanInfoConfiguredValue.TRUE,
+        require_signed_push: InheritedBooleanInfoConfiguredValue.TRUE,
+        reject_implicit_merges: InheritedBooleanInfoConfiguredValue.TRUE,
+        private_by_default: InheritedBooleanInfoConfiguredValue.TRUE,
+        work_in_progress_by_default: InheritedBooleanInfoConfiguredValue.TRUE,
+        match_author_to_committer_date:
+          InheritedBooleanInfoConfiguredValue.TRUE,
+        reject_empty_commit: InheritedBooleanInfoConfiguredValue.TRUE,
+        max_object_size_limit: '10' as MaxObjectSizeLimitInfo,
+        submit_type: SubmitType.FAST_FORWARD_ONLY,
+        state: ProjectState.READ_ONLY,
+        enable_reviewer_by_email: InheritedBooleanInfoConfiguredValue.TRUE,
+      };
+
+      const saveStub = stubRestApi('saveRepoConfig').callsFake(() =>
+        Promise.resolve(new Response())
+      );
+
+      const button = queryAll<GrButton>(element, 'gr-button')[2];
+
+      await element.loadRepo();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#Title'
+        ).classList.contains('edited')
+      );
+      queryAndAssert<GrTextarea>(element, '#descriptionInput').text =
+        configInputObj.description;
+      queryAndAssert<GrSelect>(element, '#stateSelect').bindValue =
+        configInputObj.state;
+      queryAndAssert<GrSelect>(element, '#submitTypeSelect').bindValue =
+        configInputObj.submit_type;
+      queryAndAssert<GrSelect>(element, '#contentMergeSelect').bindValue =
+        configInputObj.use_content_merge;
+      queryAndAssert<GrSelect>(element, '#newChangeSelect').bindValue =
+        configInputObj.create_new_change_for_all_not_in_target;
+      queryAndAssert<GrSelect>(element, '#requireChangeIdSelect').bindValue =
+        configInputObj.require_change_id;
+      queryAndAssert<GrSelect>(element, '#enableSignedPush').bindValue =
+        configInputObj.enable_signed_push;
+      queryAndAssert<GrSelect>(element, '#requireSignedPush').bindValue =
+        configInputObj.require_signed_push;
+      queryAndAssert<GrSelect>(
+        element,
+        '#rejectImplicitMergesSelect'
+      ).bindValue = configInputObj.reject_implicit_merges;
+      queryAndAssert<GrSelect>(
+        element,
+        '#setAllnewChangesPrivateByDefaultSelect'
+      ).bindValue = configInputObj.private_by_default;
+      queryAndAssert<GrSelect>(
+        element,
+        '#setAllNewChangesWorkInProgressByDefaultSelect'
+      ).bindValue = configInputObj.work_in_progress_by_default;
+      queryAndAssert<GrSelect>(
+        element,
+        '#matchAuthoredDateWithCommitterDateSelect'
+      ).bindValue = configInputObj.match_author_to_committer_date;
+      queryAndAssert<IronInputElement>(
+        element,
+        '#maxGitObjSizeIronInput'
+      ).bindValue = String(configInputObj.max_object_size_limit);
+      queryAndAssert<GrSelect>(
+        element,
+        '#contributorAgreementSelect'
+      ).bindValue = configInputObj.use_contributor_agreements;
+      queryAndAssert<GrSelect>(element, '#useSignedOffBySelect').bindValue =
+        configInputObj.use_signed_off_by;
+      queryAndAssert<GrSelect>(element, '#rejectEmptyCommitSelect').bindValue =
+        configInputObj.reject_empty_commit;
+      queryAndAssert<GrSelect>(element, '#unRegisteredCcSelect').bindValue =
+        configInputObj.enable_reviewer_by_email;
+
+      await element.updateComplete;
+
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.isTrue(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#configurations'
+        ).classList.contains('edited')
+      );
+
+      const formattedObj = element.formatRepoConfigForSave(element.repoConfig);
+      assert.deepEqual(formattedObj, configInputObj);
+
+      await element.handleSaveRepoConfig();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#Title'
+        ).classList.contains('edited')
+      );
+      assert.isTrue(
+        saveStub.lastCall.calledWithExactly(REPO as RepoName, configInputObj)
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 58090e1..f73b703 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -40,65 +40,7 @@
 //
 // Each object has a `view` property with a value from GerritNav.View. The
 // remaining properties depend on the value used for view.
-//
-//  - GerritNav.View.CHANGE:
-//    - `changeNum`, required, String: the numeric ID of the change.
-//    - `project`, optional, String: the project name.
-//    - `patchNum`, optional, Number: the patch for the right-hand-side of
-//        the diff.
-//    - `basePatchNum`, optional, Number: the patch for the left-hand-side
-//        of the diff. If `basePatchNum` is provided, then `patchNum` must
-//        also be provided.
-//    - `edit`, optional, Boolean: whether or not to load the file list with
-//        edit controls.
-//    - `messageHash`, optional, String: the hash of the change message to
-//        scroll to.
-//
-// - GerritNav.View.SEARCH:
-//    - `query`, optional, String: the literal search query. If provided,
-//        the string will be used as the query, and all other params will be
-//        ignored.
-//    - `owner`, optional, String: the owner name.
-//    - `project`, optional, String: the project name.
-//    - `branch`, optional, String: the branch name.
-//    - `topic`, optional, String: the topic name.
-//    - `hashtag`, optional, String: the hashtag name.
-//    - `statuses`, optional, Array<String>: the list of change statuses to
-//        search for. If more than one is provided, the search will OR them
-//        together.
-//    - `offset`, optional, Number: the offset for the query.
-//
-//  - GerritNav.View.DIFF:
-//    - `changeNum`, required, String: the numeric ID of the change.
-//    - `path`, required, String: the filepath of the diff.
-//    - `patchNum`, required, Number: the patch for the right-hand-side of
-//        the diff.
-//    - `basePatchNum`, optional, Number: the patch for the left-hand-side
-//        of the diff. If `basePatchNum` is provided, then `patchNum` must
-//        also be provided.
-//    - `lineNum`, optional, Number: the line number to be selected on load.
-//    - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value
-//        of true selects the line from base of the patch range. False by
-//        default.
-//
-//  - GerritNav.View.GROUP:
-//    - `groupId`, required, String: the ID of the group.
-//    - `detail`, optional, String: the name of the group detail view.
-//      Takes any value from GerritNav.GroupDetailView.
-//
-//  - GerritNav.View.REPO:
-//    - `repoName`, required, String: the name of the repo
-//    - `detail`, optional, String: the name of the repo detail view.
-//      Takes any value from GerritNav.RepoDetailView.
-//
-//  - GerritNav.View.DASHBOARD
-//    - `repo`, optional, String.
-//    - `sections`, optional, Array of objects with `title` and `query`
-//      strings.
-//    - `user`, optional, String.
-//
-//  - GerritNav.View.ROOT:
-//    - no possible parameters.
+// GenerateUrlParameters lists all the possible view parameters.
 
 const uninitialized = () => {
   console.warn('Use of uninitialized routing');
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
index 6a73d8d..f5a28f8 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -122,7 +122,7 @@
       return Promise.resolve([]);
     }
     return this.restApiService
-      .getSuggestedGroups(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedGroups(expression, undefined, MAX_AUTOCOMPLETE_RESULTS)
       .then(groups => {
         if (!groups) {
           return [];
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index 958f367..89ab885 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -222,11 +222,16 @@
     return result;
   }
 
-  moveToLineNumber(number: number, side: Side, path?: string) {
+  moveToLineNumber(
+    number: number,
+    side: Side,
+    path?: string,
+    intentionalMove?: boolean
+  ) {
     const row = this._findRowByNumberAndFile(number, side, path);
     if (row) {
       this.side = side;
-      this.cursorManager.setCursor(row);
+      this.cursorManager.setCursor(row, undefined, intentionalMove);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 9f65dd4..81b6fd4 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -221,8 +221,10 @@
    *
    * @param noScroll prevent any potential scrolling in response
    * setting the cursor.
+   * @param applyFocus indicates if it should try to focus after move operation
+   * (e.g. focusOnMove).
    */
-  setCursor(element: HTMLElement, noScroll?: boolean) {
+  setCursor(element: HTMLElement, noScroll?: boolean, applyFocus?: boolean) {
     if (!this.targetableStops.includes(element)) {
       this.unsetCursor();
       return;
@@ -238,6 +240,9 @@
     this._updateIndex();
     this._decorateTarget();
 
+    if (applyFocus) {
+      this._focusAfterMove();
+    }
     if (noScroll && behavior) {
       this.scrollMode = behavior;
     }
@@ -341,15 +346,17 @@
       this._targetHeight = this.target.scrollHeight;
     }
 
-    if (this.focusOnMove) {
-      this.target.focus();
-    }
-
     this._decorateTarget();
-
+    this._focusAfterMove();
     return clipped ? CursorMoveResult.CLIPPED : CursorMoveResult.MOVED;
   }
 
+  _focusAfterMove() {
+    if (this.focusOnMove) {
+      this.target?.focus();
+    }
+  }
+
   _decorateTarget() {
     if (this.target && this.cursorTargetClass) {
       this.target.classList.add(this.cursorTargetClass);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index c2b0269..9890bb2 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -238,6 +238,7 @@
   [paramName: string]: string | undefined | null | number;
   s: string;
   n?: number;
+  p?: string;
 }
 
 interface QuerySuggestedReviewersParams {
@@ -1589,12 +1590,16 @@
 
   getSuggestedGroups(
     inputVal: string,
+    project?: RepoName,
     n?: number
   ): Promise<GroupNameToGroupInfoMap | undefined> {
     const params: QueryGroupsParams = {s: inputVal};
     if (n) {
       params.n = n;
     }
+    if (project) {
+      params.p = encodeURIComponent(project);
+    }
     return this._restApiHelper.fetchJSON({
       url: '/groups/',
       params,
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 1378211..445e932 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -180,6 +180,7 @@
   ): Promise<AccountInfo[] | undefined>;
   getSuggestedGroups(
     input: string,
+    project?: RepoName,
     n?: number
   ): Promise<GroupNameToGroupInfoMap | undefined>;
   executeChangeAction(
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 91cd2f3..0fd3550 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -160,6 +160,7 @@
     work_in_progress_by_default: createInheritedBoolean(),
     max_object_size_limit: createMaxObjectSizeLimit(),
     default_submit_type: createSubmitType(),
+    enable_reviewer_by_email: createInheritedBoolean(),
     submit_type: SubmitType.INHERIT,
     commentlinks: createCommentLinks(),
   };
diff --git a/polygerrit-ui/app/utils/object-util.ts b/polygerrit-ui/app/utils/object-util.ts
new file mode 100644
index 0000000..95676c5
--- /dev/null
+++ b/polygerrit-ui/app/utils/object-util.ts
@@ -0,0 +1,24 @@
+/**
+ * @license
+ * Copyright (C) 2021 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.
+ */
+
+/**
+ * @param obj Object
+ */
+export function deepClone(obj?: object) {
+  if (!obj) return undefined;
+  return JSON.parse(JSON.stringify(obj));
+}