Merge "Workaround: process unhandled exceptions in tests"
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
deleted file mode 100644
index 9cbfda0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ /dev/null
@@ -1,382 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '@polymer/iron-input/iron-input.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-download-commands/gr-download-commands.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-subpage-styles.js';
-import '../../../styles/shared-styles.js';
-import '../gr-repo-plugin-config/gr-repo-plugin-config.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-repo_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const STATES = {
-  active: {value: 'ACTIVE', label: 'Active'},
-  readOnly: {value: 'READ_ONLY', label: 'Read Only'},
-  hidden: {value: 'HIDDEN', label: 'Hidden'},
-};
-
-const SUBMIT_TYPES = {
-  // Exclude INHERIT, which is handled specially.
-  mergeIfNecessary: {
-    value: 'MERGE_IF_NECESSARY',
-    label: 'Merge if necessary',
-  },
-  fastForwardOnly: {
-    value: 'FAST_FORWARD_ONLY',
-    label: 'Fast forward only',
-  },
-  rebaseAlways: {
-    value: 'REBASE_ALWAYS',
-    label: 'Rebase Always',
-  },
-  rebaseIfNecessary: {
-    value: 'REBASE_IF_NECESSARY',
-    label: 'Rebase if necessary',
-  },
-  mergeAlways: {
-    value: 'MERGE_ALWAYS',
-    label: 'Merge always',
-  },
-  cherryPick: {
-    value: 'CHERRY_PICK',
-    label: 'Cherry pick',
-  },
-};
-
-/**
- * @extends PolymerElement
- */
-class GrRepo extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  // Notes for future TS conversion:
-  // _repoConfig: ConfigInfo
-  // _pluginData: PluginData[], can't be null, PluginData from gr-repo-plugin-config.ts
-
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-repo'; }
-
-  static get properties() {
-    return {
-      params: Object,
-      repo: String,
-
-      _configChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-        observer: '_loggedInChanged',
-      },
-      /** @type {?} */
-      _repoConfig: Object,
-      /** @type {?} */
-      _pluginData: {
-        type: Array,
-        computed: '_computePluginData(_repoConfig.plugin_config.*)',
-      },
-      _readOnly: {
-        type: Boolean,
-        value: true,
-      },
-      _states: {
-        type: Array,
-        value() {
-          return Object.values(STATES);
-        },
-      },
-      _submitTypes: {
-        type: Array,
-        value() {
-          return Object.values(SUBMIT_TYPES);
-        },
-      },
-      _schemes: {
-        type: Array,
-        value() { return []; },
-        computed: '_computeSchemes(_schemesObj)',
-        observer: '_schemesChanged',
-      },
-      _selectedCommand: {
-        type: String,
-        value: 'Clone',
-      },
-      _selectedScheme: String,
-      _schemesObj: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_handleConfigChanged(_repoConfig.*)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadRepo();
-
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: this.repo},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _computePluginData(configRecord) {
-    if (!configRecord ||
-        !configRecord.base) { return []; }
-
-    const pluginConfig = configRecord.base;
-    return Object.keys(pluginConfig)
-        .map(name => { return {name, config: pluginConfig[name]}; });
-  }
-
-  _loadRepo() {
-    if (!this.repo) { return Promise.resolve(); }
-
-    const promises = [];
-
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-
-    promises.push(this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.$.restAPI.getRepoAccess(this.repo).then(access => {
-          if (!access) { return Promise.resolve(); }
-
-          // If the user is not an owner, is_owner is not a property.
-          this._readOnly = !access[this.repo].is_owner;
-        });
-      }
-    }));
-
-    promises.push(this.$.restAPI.getProjectConfig(this.repo, errFn)
-        .then(config => {
-          if (!config) { return Promise.resolve(); }
-
-          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.$.restAPI.getConfig().then(config => {
-      if (!config) { return Promise.resolve(); }
-
-      this._schemesObj = config.download.schemes;
-    }));
-
-    return Promise.all(promises);
-  }
-
-  _computeLoadingClass(loading) {
-    return loading ? 'loading' : '';
-  }
-
-  _computeHideClass(arr) {
-    return !arr || !arr.length ? 'hide' : '';
-  }
-
-  _loggedInChanged(_loggedIn) {
-    if (!_loggedIn) { return; }
-    this.$.restAPI.getPreferences().then(prefs => {
-      if (prefs.download_scheme) {
-        // Note (issue 5180): normalize the download scheme with lower-case.
-        this._selectedScheme = prefs.download_scheme.toLowerCase();
-      }
-    });
-  }
-
-  _formatBooleanSelect(item) {
-    if (!item) { return; }
-    let inheritLabel = 'Inherit';
-    if (!(item.inherited_value === undefined)) {
-      inheritLabel = `Inherit (${item.inherited_value})`;
-    }
-    return [
-      {
-        label: inheritLabel,
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ];
-  }
-
-  _formatSubmitTypeSelect(projectConfig) {
-    if (!projectConfig) { return; }
-    const allValues = Object.values(SUBMIT_TYPES);
-    const type = projectConfig.default_submit_type;
-    if (!type) {
-      // Server is too old to report default_submit_type, so assume INHERIT
-      // is not a valid value.
-      return allValues;
-    }
-
-    let inheritLabel = 'Inherit';
-    if (type.inherited_value) {
-      let inherited = type.inherited_value;
-      for (const val of allValues) {
-        if (val.value === type.inherited_value) {
-          inherited = val.label;
-          break;
-        }
-      }
-      inheritLabel = `Inherit (${inherited})`;
-    }
-    return [
-      {
-        label: inheritLabel,
-        value: 'INHERIT',
-      },
-      ...allValues,
-    ];
-  }
-
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _formatRepoConfigForSave(repoConfig) {
-    const configInputObj = {};
-    for (const key in repoConfig) {
-      if (repoConfig.hasOwnProperty(key)) {
-        if (key === 'default_submit_type') {
-          // default_submit_type is not in the input type, and the
-          // configured value was already copied to submit_type by
-          // _loadProject. Omit this property when saving.
-          continue;
-        }
-        if (key === 'plugin_config') {
-          configInputObj.plugin_config_values = repoConfig[key];
-        } else if (typeof repoConfig[key] === 'object') {
-          configInputObj[key] = repoConfig[key].configured_value;
-        } else {
-          configInputObj[key] = repoConfig[key];
-        }
-      }
-    }
-    return configInputObj;
-  }
-
-  _handleSaveRepoConfig() {
-    return this.$.restAPI.saveRepoConfig(this.repo,
-        this._formatRepoConfigForSave(this._repoConfig)).then(() => {
-      this._configChanged = false;
-    });
-  }
-
-  _handleConfigChanged() {
-    if (this._isLoading()) { return; }
-    this._configChanged = true;
-  }
-
-  _computeButtonDisabled(readOnly, configChanged) {
-    return readOnly || !configChanged;
-  }
-
-  _computeHeaderClass(configChanged) {
-    return configChanged ? 'edited' : '';
-  }
-
-  _computeSchemes(schemesObj) {
-    return Object.keys(schemesObj);
-  }
-
-  _schemesChanged(schemes) {
-    if (schemes.length === 0) { return; }
-    if (!schemes.includes(this._selectedScheme)) {
-      this._selectedScheme = schemes.sort()[0];
-    }
-  }
-
-  _computeCommands(repo, schemesObj, _selectedScheme) {
-    if (!schemesObj || !repo || !_selectedScheme) {
-      return [];
-    }
-    const commands = [];
-    let commandObj;
-    if (schemesObj.hasOwnProperty(_selectedScheme)) {
-      commandObj = schemesObj[_selectedScheme].clone_commands;
-    }
-    for (const title in commandObj) {
-      if (!commandObj.hasOwnProperty(title)) { continue; }
-      commands.push({
-        title,
-        command: commandObj[title]
-            .replace(/\${project}/gi, encodeURI(repo))
-            .replace(/\${project-base-name}/gi,
-                encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
-      });
-    }
-    return commands;
-  }
-
-  _computeRepositoriesClass(config) {
-    return config ? 'showConfig': '';
-  }
-
-  _computeChangesUrl(name) {
-    return GerritNav.getUrlForProjectChanges(name);
-  }
-
-  _handlePluginConfigChanged({detail: {name, config, notifyPath}}) {
-    this._repoConfig.plugin_config[name] = config;
-    this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
-  }
-}
-
-customElements.define(GrRepo.is, GrRepo);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
new file mode 100644
index 0000000..101c77a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -0,0 +1,455 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '@polymer/iron-input/iron-input';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-download-commands/gr-download-commands';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/shared-styles';
+import '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+  RestApiService,
+  ErrorCallback,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ConfigInfo,
+  RepoName,
+  InheritedBooleanInfo,
+  SchemesInfoMap,
+  ConfigInput,
+  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 {hasOwnProperty} from '../../../utils/common-util';
+
+const STATES = {
+  active: {value: ProjectState.ACTIVE, label: 'Active'},
+  readOnly: {value: ProjectState.READ_ONLY, label: 'Read Only'},
+  hidden: {value: ProjectState.HIDDEN, label: 'Hidden'},
+};
+
+const SUBMIT_TYPES = {
+  // Exclude INHERIT, which is handled specially.
+  mergeIfNecessary: {
+    value: 'MERGE_IF_NECESSARY',
+    label: 'Merge if necessary',
+  },
+  fastForwardOnly: {
+    value: 'FAST_FORWARD_ONLY',
+    label: 'Fast forward only',
+  },
+  rebaseAlways: {
+    value: 'REBASE_ALWAYS',
+    label: 'Rebase Always',
+  },
+  rebaseIfNecessary: {
+    value: 'REBASE_IF_NECESSARY',
+    label: 'Rebase if necessary',
+  },
+  mergeAlways: {
+    value: 'MERGE_ALWAYS',
+    label: 'Merge always',
+  },
+  cherryPick: {
+    value: 'CHERRY_PICK',
+    label: 'Cherry pick',
+  },
+};
+
+export interface GrRepo {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-repo')
+export class GrRepo extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  repo?: RepoName;
+
+  @property({type: Boolean})
+  _configChanged = false;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Boolean, observer: '_loggedInChanged'})
+  _loggedIn = false;
+
+  @property({type: Object})
+  _repoConfig?: ConfigInfo;
+
+  @property({
+    type: Array,
+    computed: '_computePluginData(_repoConfig.plugin_config.*)',
+  })
+  _pluginData?: PluginData[];
+
+  @property({type: Boolean})
+  _readOnly = true;
+
+  @property({type: Array})
+  _states = Object.values(STATES);
+
+  @property({
+    type: Array,
+    computed: '_computeSchemes(_schemesDefault, _schemesObj)',
+    observer: '_schemesChanged',
+  })
+  _schemes: string[] = [];
+
+  // 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;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadRepo();
+
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: this.repo},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _computePluginData(
+    configRecord: PolymerDeepPropertyChange<
+      PluginNameToPluginParametersMap,
+      PluginNameToPluginParametersMap
+    >
+  ) {
+    if (!configRecord || !configRecord.base) {
+      return [];
+    }
+
+    const pluginConfig = configRecord.base;
+    return Object.keys(pluginConfig).map(name => {
+      return {name, config: pluginConfig[name]};
+    });
+  }
+
+  _loadRepo() {
+    if (!this.repo) {
+      return Promise.resolve();
+    }
+
+    const promises = [];
+
+    const errFn: ErrorCallback = response => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+
+    promises.push(
+      this._getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+        if (loggedIn) {
+          const repo = this.repo;
+          if (!repo) throw new Error('undefined repo');
+          this.$.restAPI.getRepoAccess(repo).then(access => {
+            if (!access || this.repo !== repo) {
+              return;
+            }
+
+            // If the user is not an owner, is_owner is not a property.
+            this._readOnly = !access[repo].is_owner;
+          });
+        }
+      })
+    );
+
+    promises.push(
+      this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+        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.$.restAPI.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.$.restAPI.getPreferences().then(prefs => {
+      if (prefs?.download_scheme) {
+        // Note (issue 5180): normalize the download scheme with lower-case.
+        this._selectedScheme = prefs.download_scheme.toLowerCase();
+      }
+    });
+  }
+
+  _formatBooleanSelect(item: InheritedBooleanInfo) {
+    if (!item) {
+      return;
+    }
+    let inheritLabel = 'Inherit';
+    if (!(item.inherited_value === undefined)) {
+      inheritLabel = `Inherit (${item.inherited_value})`;
+    }
+    return [
+      {
+        label: inheritLabel,
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      },
+      {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ];
+  }
+
+  _formatSubmitTypeSelect(projectConfig: ConfigInfo) {
+    if (!projectConfig) {
+      return;
+    }
+    const allValues = Object.values(SUBMIT_TYPES);
+    const type = projectConfig.default_submit_type;
+    if (!type) {
+      // Server is too old to report default_submit_type, so assume INHERIT
+      // is not a valid value.
+      return allValues;
+    }
+
+    let inheritLabel = 'Inherit';
+    if (type.inherited_value) {
+      inheritLabel = `Inherit (${type.inherited_value})`;
+      for (const val of allValues) {
+        if (val.value === type.inherited_value) {
+          inheritLabel = `Inherit (${val.label})`;
+          break;
+        }
+      }
+    }
+    return [
+      {
+        label: inheritLabel,
+        value: 'INHERIT',
+      },
+      ...allValues,
+    ];
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _formatRepoConfigForSave(repoConfig: ConfigInfo): ConfigInput {
+    const configInputObj: ConfigInput = {};
+    for (const configKey of Object.keys(repoConfig)) {
+      const key = configKey as keyof ConfigInfo;
+      if (key === 'default_submit_type') {
+        // default_submit_type is not in the input type, and the
+        // configured value was already copied to submit_type by
+        // _loadProject. Omit this property when saving.
+        continue;
+      }
+      if (key === 'plugin_config') {
+        configInputObj.plugin_config_values = repoConfig.plugin_config;
+      } else if (typeof repoConfig[key] === 'object') {
+        const repoConfigObj: any = repoConfig[key];
+        if (repoConfigObj.configured_value) {
+          configInputObj[key as keyof ConfigInput] =
+            repoConfigObj.configured_value;
+        }
+      } else {
+        configInputObj[key as keyof ConfigInput] = repoConfig[key] as any;
+      }
+    }
+    return configInputObj;
+  }
+
+  _handleSaveRepoConfig() {
+    if (!this._repoConfig || !this.repo)
+      return Promise.reject(new Error('undefined repoConfig or repo'));
+    return this.$.restAPI
+      .saveRepoConfig(
+        this.repo,
+        this._formatRepoConfigForSave(this._repoConfig)
+      )
+      .then(() => {
+        this._configChanged = false;
+      });
+  }
+
+  @observe('_repoConfig.*')
+  _handleConfigChanged() {
+    if (this._isLoading()) {
+      return;
+    }
+    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._selectedScheme || !schemes.includes(this._selectedScheme)) {
+      this._selectedScheme = schemes.sort()[0];
+    }
+  }
+
+  _computeCommands(
+    repo?: RepoName,
+    schemesObj?: SchemesInfoMap,
+    _selectedScheme?: string
+  ) {
+    if (!schemesObj || !repo || !_selectedScheme) {
+      return [];
+    }
+    const commands = [];
+    let commandObj: {[title: string]: string} = {};
+    if (hasOwnProperty(schemesObj, _selectedScheme)) {
+      commandObj = schemesObj[_selectedScheme].clone_commands;
+    }
+    for (const title in commandObj) {
+      if (!hasOwnProperty(commandObj, title)) {
+        continue;
+      }
+      commands.push({
+        title,
+        command: commandObj[title]
+          .replace(/\${project}/gi, encodeURI(repo))
+          .replace(
+            /\${project-base-name}/gi,
+            encodeURI(repo.substring(repo.lastIndexOf('/') + 1))
+          ),
+      });
+    }
+    return commands;
+  }
+
+  _computeRepositoriesClass(config: InheritedBooleanInfo) {
+    return config ? 'showConfig' : '';
+  }
+
+  _computeChangesUrl(name: RepoName) {
+    return GerritNav.getUrlForProjectChanges(name);
+  }
+
+  _handlePluginConfigChanged({
+    detail: {name, config, notifyPath},
+  }: {
+    detail: {
+      name: string;
+      config: PluginParameterToConfigParameterInfoMap;
+      notifyPath: string;
+    };
+  }) {
+    if (this._repoConfig?.plugin_config) {
+      this._repoConfig.plugin_config[name] = config;
+      this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo': GrRepo;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 68e65bc..23e024b 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -470,8 +470,7 @@
     // prior to it being saved.
     this.cancelDebouncer('store');
 
-    if (!this.comment?.path || this.comment.line === undefined)
-      throw new Error('Cannot erase Draft Comment');
+    if (!this.comment?.path) throw new Error('Cannot erase Draft Comment');
     if (this.changeNum === undefined) {
       throw new Error('undefined changeNum');
     }
@@ -628,7 +627,7 @@
       ? this.comment.patch_set
       : this._getPatchNum();
     const {path, line, range} = this.comment;
-    if (path && line !== undefined) {
+    if (path) {
       this.debounce(
         'store',
         () => {
@@ -659,9 +658,7 @@
 
   _handleAnchorClick(e: Event) {
     e.preventDefault();
-    if (!this.comment?.line) {
-      return;
-    }
+    if (!this.comment) return;
     this.dispatchEvent(
       new CustomEvent('comment-anchor-tap', {
         bubbles: true,
@@ -944,8 +941,7 @@
       comment.id ||
       comment.message ||
       comment.__otherEditing ||
-      !comment.path ||
-      !comment.line
+      !comment.path
     ) {
       if (comment) delete comment.__otherEditing;
       return;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
index 6cda593..3847135 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -19,6 +19,7 @@
 import './gr-comment.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromElement('gr-comment');
 
@@ -727,6 +728,27 @@
       });
     });
 
+    test('patchset level comment', done => {
+      const comment = {...element.comment,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS, line: undefined,
+        range: undefined};
+      element.comment = comment;
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      assert.isTrue(element.editing);
+
+      element._messageText = 'hello world';
+      const eraseMessageDraftSpy = sinon.spy(element.$.storage,
+          'eraseDraftComment');
+      const mockEvent = {preventDefault: sinon.stub()};
+      element._handleSave(mockEvent);
+      flush(() => {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+    });
+
     test('draft creation/cancellation', done => {
       assert.isFalse(element.editing);
       element.draft = true;
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 ffdd662..47a1cbb 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
@@ -34,6 +34,18 @@
 // Time in which pressing n key again after the toast navigates to next file
 const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
 
+/**
+ * Return type for cursor moves, that indicate whether a move was possible.
+ */
+export enum CursorMoveResult {
+  /** The cursor was successfully moved. */
+  MOVED,
+  /** There were no stops - the cursor was reset. */
+  NO_STOPS,
+  /** There was no more stop to move to - the cursor was clipped to the end. */
+  CLIPPED,
+}
+
 @customElement('gr-cursor-manager')
 export class GrCursorManager extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -104,16 +116,16 @@
    *     back to first instead of to last.
    * @param navigateToNextFile Navigate to next unreviewed file
    *     if user presses next on the last diff chunk
+   * @return If a move was performed or why not.
    * @private
    */
-
   next(
     condition?: Function,
     getTargetHeight?: (target: HTMLElement) => number,
     clipToTop?: boolean,
     navigateToNextFile?: boolean
-  ) {
-    this._moveCursor(
+  ): CursorMoveResult {
+    return this._moveCursor(
       1,
       condition,
       getTargetHeight,
@@ -122,8 +134,8 @@
     );
   }
 
-  previous(condition?: Function) {
-    this._moveCursor(-1, condition);
+  previous(condition?: Function): CursorMoveResult {
+    return this._moveCursor(-1, condition);
   }
 
   /**
@@ -269,6 +281,7 @@
    * back to first instead of to last.
    * @param navigateToNextFile Navigate to next unreviewed file
    * if user presses next on the last diff chunk
+   * @return  If a move was performed or why not.
    * @private
    */
   _moveCursor(
@@ -277,27 +290,25 @@
     getTargetHeight?: (target: HTMLElement) => number,
     clipToTop?: boolean,
     navigateToNextFile?: boolean
-  ) {
+  ): CursorMoveResult {
     if (!this.stops.length) {
       this.unsetCursor();
-      return;
+      return CursorMoveResult.NO_STOPS;
     }
 
     this._unDecorateTarget();
 
     const newIndex = this._getNextindex(delta, condition, clipToTop);
+    const newTarget = newIndex !== -1 ? this.stops[newIndex] : null;
 
-    let newTarget = null;
-    if (newIndex !== -1) {
-      newTarget = this.stops[newIndex];
-    }
+    const clipped = this.index === newIndex;
 
     /*
      * If user presses n on the last diff chunk, show a toast informing user
      * that pressing n again will navigate them to next unreviewed file.
      * If click happens within the time limit, then navigate to next file
      */
-    if (navigateToNextFile && this.index === newIndex && this.isAtEnd()) {
+    if (navigateToNextFile && clipped && this.isAtEnd()) {
       if (
         this._lastDisplayedNavigateToNextFileToast &&
         Date.now() - this._lastDisplayedNavigateToNextFileToast <=
@@ -311,7 +322,7 @@
             bubbles: true,
           })
         );
-        return;
+        return CursorMoveResult.CLIPPED;
       }
       this._lastDisplayedNavigateToNextFileToast = Date.now();
       this.dispatchEvent(
@@ -323,14 +334,14 @@
           bubbles: true,
         })
       );
-      return;
+      return CursorMoveResult.CLIPPED;
     }
 
     this.index = newIndex;
     this.target = newTarget as HTMLElement;
 
     if (!newTarget) {
-      return;
+      return CursorMoveResult.NO_STOPS;
     }
 
     if (getTargetHeight) {
@@ -344,6 +355,8 @@
     }
 
     this._decorateTarget();
+
+    return clipped ? CursorMoveResult.CLIPPED : CursorMoveResult.MOVED;
   }
 
   _decorateTarget() {
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index bc07d84..5c0bb42 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-cursor-manager.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {CursorMoveResult} from './gr-cursor-manager.js';
 
 const basicTestFixutre = fixtureFromTemplate(html`
     <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
@@ -66,10 +67,11 @@
     assert.isFalse(element.isAtEnd());
 
     // Progress the cursor.
-    element.next();
+    let result = element.next();
 
     // Confirm that the next stop is selected and that the previous stop is
     // unselected.
+    assert.equal(result, CursorMoveResult.MOVED);
     assert.equal(element.index, 3);
     assert.equal(element.target, list.children[3]);
     assert.isTrue(element.isAtEnd());
@@ -77,19 +79,23 @@
     assert.isTrue(list.children[3].classList.contains('targeted'));
 
     // Progress the cursor.
-    element.next();
+    result = element.next();
 
     // We should still be at the end.
+    assert.equal(result, CursorMoveResult.CLIPPED);
     assert.equal(element.index, 3);
     assert.equal(element.target, list.children[3]);
     assert.isTrue(element.isAtEnd());
 
     // Wind the cursor all the way back to the first stop.
-    element.previous();
-    element.previous();
-    element.previous();
+    result = element.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
+    result = element.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
+    result = element.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
 
-    // The element state should reflect the end of the list.
+    // The element state should reflect the start of the list.
     assert.equal(element.index, 0);
     assert.equal(element.target, list.children[0]);
     assert.isTrue(element.isAtStart());
@@ -113,8 +119,9 @@
 
   test('next() goes to first element when no cursor is set', () => {
     element.stops = list.querySelectorAll('li');
-    element.next();
+    const result = element.next();
 
+    assert.equal(result, CursorMoveResult.MOVED);
     assert.equal(element.index, 0);
     assert.equal(element.target, list.children[0]);
     assert.isTrue(list.children[0].classList.contains('targeted'));
@@ -122,10 +129,23 @@
     assert.isFalse(element.isAtEnd());
   });
 
-  test('next() goes to first element when no cursor is set', () => {
-    element.stops = list.querySelectorAll('li');
-    element.previous();
+  test('next() resets the cursor when there are no stops', () => {
+    element.stops = [];
+    const result = element.next();
 
+    assert.equal(result, CursorMoveResult.NO_STOPS);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isFalse(element.isAtEnd());
+  });
+
+  test('previous() goes to last element when no cursor is set', () => {
+    element.stops = list.querySelectorAll('li');
+    const result = element.previous();
+
+    assert.equal(result, CursorMoveResult.MOVED);
     const lastIndex = list.children.length - 1;
     assert.equal(element.index, lastIndex);
     assert.equal(element.target, list.children[lastIndex]);
@@ -134,6 +154,18 @@
     assert.isTrue(element.isAtEnd());
   });
 
+  test('previous() resets the cursor when there are no stops', () => {
+    element.stops = [];
+    const result = element.previous();
+
+    assert.equal(result, CursorMoveResult.NO_STOPS);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isFalse(element.isAtEnd());
+  });
+
   test('_moveCursor', () => {
     // Initialize the cursor with its stops.
     element.stops = list.querySelectorAll('li');
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
index 468bbee..bc1dfe0 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
@@ -55,6 +55,7 @@
       list-style-type: disc;
       margin-left: var(--spacing-xl);
     }
+    code,
     gr-linked-text.pre {
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-code);
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
index 15914c5..3233420 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
@@ -24,7 +24,7 @@
   changeNum: number;
   patchNum: PatchSetNum;
   path: string;
-  line: number;
+  line?: number;
   range?: CommentRange;
 }
 
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 5565180..c85e307 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -81,6 +81,7 @@
   UrlEncodedCommentId,
   TagInfo,
   GitRef,
+  ConfigInput,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {HttpMethod} from '../../../constants/constants';
@@ -688,4 +689,5 @@
   setRepoHead(repo: RepoName, ref: GitRef): Promise<Response>;
   deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
   deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
+  saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
 }
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index ea7fb75..e7539b5 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -793,16 +793,19 @@
   new_value: string;
 }
 
+export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
+
 /**
  * The DownloadInfo entity contains information about supported download
  * options.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
  */
 export interface DownloadInfo {
-  schemes: string;
+  schemes: SchemesInfoMap;
   archives: string;
 }
 
+export type CloneCommandMap = {[name: string]: string};
 /**
  * The DownloadSchemeInfo entity contains information about a supported download
  * scheme and its commands.
@@ -813,7 +816,7 @@
   is_auth_required: boolean;
   is_auth_supported: boolean;
   commands: string;
-  clone_commands: string;
+  clone_commands: CloneCommandMap;
 }
 
 /**
@@ -1347,14 +1350,6 @@
   reject_empty_commit?: InheritedBooleanInfo;
 }
 
-export type PluginParameterToConfigParameterInfoMap = {
-  [parameterName: string]: ConfigParameterInfo;
-};
-
-export type PluginNameToPluginParametersMap = {
-  [pluginName: string]: PluginParameterToConfigParameterInfoMap;
-};
-
 /**
  * The ProjectAccessInfo entity contains information about the access rights for a project
  * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#project-access-info
@@ -1447,23 +1442,31 @@
   use_signed_off_by?: InheritedBooleanInfoConfiguredValue;
   create_new_change_for_all_not_in_target?: InheritedBooleanInfoConfiguredValue;
   require_change_id?: InheritedBooleanInfoConfiguredValue;
+  enable_signed_push?: InheritedBooleanInfoConfiguredValue;
+  require_signed_push?: InheritedBooleanInfoConfiguredValue;
+  private_by_default?: InheritedBooleanInfoConfiguredValue;
+  work_in_progress_by_default?: InheritedBooleanInfoConfiguredValue;
+  enable_reviewer_by_email?: InheritedBooleanInfoConfiguredValue;
+  match_author_to_committer_date?: InheritedBooleanInfoConfiguredValue;
   reject_implicit_merges?: InheritedBooleanInfoConfiguredValue;
+  reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
   max_object_size_limit?: MaxObjectSizeLimitInfo;
   submit_type?: SubmitType;
   state?: ProjectState;
-  plugin_config_values?: PluginConfigValues;
-  reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
+  plugin_config_values?: PluginNameToPluginParametersMap;
   commentlinks?: ConfigInfoCommentLinks;
 }
-
 /**
  * Plugin configuration values as map which maps the plugin name to a map of parameter names to values
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
  */
-export type PluginConfigValues = {
-  [pluginName: string]: ParameterNameToValueMap;
+export type PluginNameToPluginParametersMap = {
+  [pluginName: string]: PluginParameterToConfigParameterInfoMap;
 };
-export type ParameterNameToValueMap = {[parameterName: string]: string};
+
+export type PluginParameterToConfigParameterInfoMap = {
+  [parameterName: string]: ConfigParameterInfo;
+};
 
 export type ConfigInfoCommentLinks = {
   [commentLinkName: string]: CommentLinkInfo;
@@ -1490,7 +1493,6 @@
   enable_signed_push?: InheritedBooleanInfoConfiguredValue;
   require_signed_push?: InheritedBooleanInfoConfiguredValue;
   max_object_size_limit?: string;
-  plugin_config_values?: PluginConfigValues;
   reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
 }