Merge changes from topic "issue-8535" into stable-3.0

* changes:
  Implement per-project plugin configuration
  Add gr-repo-plugin-config
  Add gr-plugin-config-array-editor
diff --git a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html
new file mode 100644
index 0000000..2dc070d
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html
@@ -0,0 +1,38 @@
+<!--
+@license
+Copyright (C) 2019 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.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior this */
+  Gerrit.RepoPluginConfig = {
+    // Should be kept in sync with
+    // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
+    ENTRY_TYPES: {
+      ARRAY: 'ARRAY',
+      BOOLEAN: 'BOOLEAN',
+      INT: 'INT',
+      LIST: 'LIST',
+      LONG: 'LONG',
+      STRING: 'STRING',
+    },
+    PLUGIN_CONFIG_CHANGED: 'plugin-config-changed',
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
new file mode 100644
index 0000000..ca98c50
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
@@ -0,0 +1,100 @@
+<!--
+@license
+Copyright (C) 2018 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+
+<dom-module id="gr-plugin-config-array-editor">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      .wrapper {
+        width: 30em;
+      }
+      .existingItems {
+        background: var(--table-header-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: 2px;
+      }
+      gr-button {
+        float: right;
+        margin-left: .5em;
+        width: 4.5em;
+      }
+      .row {
+        align-items: center;
+        display: flex;
+        justify-content: space-between;
+        padding: .5em 0;
+        width: 100%;
+      }
+      .existingItems .row {
+        padding: .5em;
+      }
+      .existingItems .row:not(:first-of-type) {
+        border-top: 1px solid var(--border-color);
+      }
+      input {
+        flex-grow: 1;
+      }
+      .hide {
+        display: none;
+      }
+      .placeholder {
+        color: var(--deemphasized-text-color);
+        padding-top: .75em;
+      }
+    </style>
+    <div class="wrapper gr-form-styles">
+      <template is="dom-if" if="[[pluginOption.info.values.length]]">
+        <div class="existingItems">
+          <template is="dom-repeat" items="[[pluginOption.info.values]]">
+            <div class="row">
+              <span>[[item]]</span>
+              <gr-button
+                  link
+                  disabled$="[[disabled]]"
+                  data-item="[[item]]"
+                  on-tap="_handleDelete">Delete</gr-button>
+            </div>
+          </template>
+        </div>
+      </template>
+      <template is="dom-if" if="[[!pluginOption.info.values.length]]">
+        <div class="row placeholder">None configured.</div>
+      </template>
+      <div class$="row [[_computeShowInputRow(disabled)]]">
+        <input
+            is="iron-input"
+            id="input"
+            on-keydown="_handleInputKeydown"
+            bind-value="{{_newValue}}"/>
+        <gr-button
+            id="addButton"
+            disabled$="[[!_newValue.length]]"
+            link
+            on-tap="_handleAddTap">Add</gr-button>
+      </div>
+    </div>
+  </template>
+  <script src="gr-plugin-config-array-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
new file mode 100644
index 0000000..ab4d286
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright (C) 2018 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.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-plugin-config-array-editor',
+
+    /**
+     * Fired when the plugin config option changes.
+     *
+     * @event plugin-config-option-changed
+     */
+
+    properties: {
+      /** @type {?} */
+      pluginOption: Object,
+      /** @type {Boolean} */
+      disabled: {
+        type: Boolean,
+        computed: '_computeDisabled(pluginOption.*)',
+      },
+      /** @type {?} */
+      _newValue: {
+        type: String,
+        value: '',
+      },
+    },
+
+    _computeDisabled(record) {
+      return !(record && record.base && record.base.info &&
+          record.base.info.editable);
+    },
+
+    _handleAddTap(e) {
+      e.preventDefault();
+      this._handleAdd();
+    },
+
+    _handleInputKeydown(e) {
+      // Enter.
+      if (e.keyCode === 13) {
+        e.preventDefault();
+        this._handleAdd();
+      }
+    },
+
+    _handleAdd() {
+      if (!this._newValue.length) { return; }
+      this._dispatchChanged(
+          this.pluginOption.info.values.concat([this._newValue]));
+      this._newValue = '';
+    },
+
+    _handleDelete(e) {
+      const value = Polymer.dom(e).localTarget.dataItem;
+      this._dispatchChanged(
+          this.pluginOption.info.values.filter(str => str !== value));
+    },
+
+    _dispatchChanged(values) {
+      const {_key, info} = this.pluginOption;
+      const detail = {
+        _key,
+        info: Object.assign(info, {values}, {}),
+        notifyPath: `${_key}.values`,
+      };
+      this.dispatchEvent(
+          new CustomEvent('plugin-config-option-changed', {detail}));
+    },
+
+    _computeShowInputRow(disabled) {
+      return disabled ? 'hide' : '';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
new file mode 100644
index 0000000..dc3f67e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
@@ -0,0 +1,142 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-config-array-editor</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-plugin-config-array-editor.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-plugin-config-array-editor></gr-plugin-config-array-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-plugin-config-array-editor tests', () => {
+    let element;
+    let sandbox;
+    let dispatchStub;
+
+    const getAll = str => Polymer.dom(element.root).querySelectorAll(str);
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.pluginOption = {
+        _key: 'test-key',
+        info: {
+          values: [],
+        },
+      };
+    });
+
+    teardown(() => sandbox.restore());
+
+    test('_computeShowInputRow', () => {
+      assert.equal(element._computeShowInputRow(true), 'hide');
+      assert.equal(element._computeShowInputRow(false), '');
+    });
+
+    test('_computeDisabled', () => {
+      assert.isTrue(element._computeDisabled({}));
+      assert.isTrue(element._computeDisabled({base: {}}));
+      assert.isTrue(element._computeDisabled({base: {info: {}}}));
+      assert.isTrue(
+          element._computeDisabled({base: {info: {editable: false}}}));
+      assert.isFalse(
+          element._computeDisabled({base: {info: {editable: true}}}));
+    });
+
+    suite('adding', () => {
+      setup(() => {
+        dispatchStub = sandbox.stub(element, '_dispatchChanged');
+      });
+
+      test('with enter', () => {
+        element._newValue = '';
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        flushAsynchronousOperations();
+
+        assert.isFalse(dispatchStub.called);
+        element._newValue = 'test';
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        flushAsynchronousOperations();
+
+        assert.isTrue(dispatchStub.called);
+        assert.equal(dispatchStub.lastCall.args[0], 'test');
+        assert.equal(element._newValue, '');
+      });
+
+      test('with add btn', () => {
+        element._newValue = '';
+        MockInteractions.tap(element.$.addButton);
+        flushAsynchronousOperations();
+
+        assert.isFalse(dispatchStub.called);
+        element._newValue = 'test';
+        MockInteractions.tap(element.$.addButton);
+        flushAsynchronousOperations();
+
+        assert.isTrue(dispatchStub.called);
+        assert.equal(dispatchStub.lastCall.args[0], 'test');
+        assert.equal(element._newValue, '');
+      });
+    });
+
+    test('deleting', () => {
+      dispatchStub = sandbox.stub(element, '_dispatchChanged');
+      element.pluginOption = {info: {values: ['test', 'test2']}};
+      flushAsynchronousOperations();
+
+      const rows = getAll('.existingItems .row');
+      assert.equal(rows.length, 2);
+      const button = rows[0].querySelector('gr-button');
+
+      MockInteractions.tap(button);
+      flushAsynchronousOperations();
+
+      assert.isFalse(dispatchStub.called);
+      element.pluginOption.info.editable = true;
+      element.notifyPath('pluginOption.info.editable');
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(button);
+      flushAsynchronousOperations();
+
+      assert.isTrue(dispatchStub.called);
+      assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
+    });
+
+    test('_dispatchChanged', () => {
+      const eventStub = sandbox.stub(element, 'dispatchEvent');
+      element._dispatchChanged(['new-test-value']);
+
+      assert.isTrue(eventStub.called);
+      const {detail} = eventStub.lastCall.args[0];
+      assert.equal(detail._key, 'test-key');
+      assert.deepEqual(detail.info, {values: ['new-test-value']});
+      assert.equal(detail.notifyPath, 'test-key.values');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html
new file mode 100644
index 0000000..7f2cbe7
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html
@@ -0,0 +1,109 @@
+<!--
+@license
+Copyright (C) 2018 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+
+<link rel="import" href="../../../behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/gr-subpage-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
+<link rel="import" href="../gr-plugin-config-array-editor/gr-plugin-config-array-editor.html">
+
+<dom-module id="gr-repo-plugin-config">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles"></style>
+    <style include="gr-subpage-styles">
+      .inherited {
+        color: var(--deemphasized-text-color);
+        margin-left: .5em;
+      }
+      section.section:not(.ARRAY) .title {
+        align-items: center;
+        display: flex;
+      }
+      section.section.ARRAY .title {
+        padding-top: .75em;
+      }
+    </style>
+    <div class="gr-form-styles">
+      <fieldset>
+        <h4>[[pluginData.name]]</h4>
+        <template is="dom-repeat" items="[[_pluginConfigOptions]]" as="option">
+          <section class$="section [[option.info.type]]">
+            <span class="title">
+              <gr-tooltip-content
+                  has-tooltip="[[option.info.description]]"
+                  show-icon="[[option.info.description]]"
+                  title="[[option.info.description]]">
+                <span>[[option.info.display_name]]</span>
+              </gr-tooltip-content>
+            </span>
+            <span class="value">
+              <template is="dom-if" if="[[_isArray(option.info.type)]]">
+                <gr-plugin-config-array-editor
+                    on-plugin-config-option-changed="_handleArrayChange"
+                    plugin-option="[[option]]"></gr-plugin-config-array-editor>
+              </template>
+              <template is="dom-if" if="[[_isBoolean(option.info.type)]]">
+                <paper-toggle-button
+                    checked="[[_computeChecked(option.info.value)]]"
+                    on-change="_handleBooleanChange"
+                    data-option-key$="[[option._key]]"
+                    disabled$="[[_computeDisabled(option.info.editable)]]"></paper-toggle-button>
+              </template>
+              <template is="dom-if" if="[[_isList(option.info.type)]]">
+                <gr-select
+                    bind-value$="[[option.info.value]]"
+                    on-change="_handleListChange">
+                  <select
+                      data-option-key$="[[option._key]]"
+                      disabled$="[[_computeDisabled(option.info.editable)]]">
+                    <template is="dom-repeat"
+                        items="[[option.info.permitted_values]]"
+                        as="value">
+                      <option value$="[[value]]">[[value]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </template>
+              <template is="dom-if" if="[[_isString(option.info.type)]]">
+                <input
+                    is="iron-input"
+                    value="[[option.info.value]]"
+                    on-input="_handleStringChange"
+                    data-option-key$="[[option._key]]"
+                    disabled$="[[_computeDisabled(option.info.editable)]]"></input>
+              </template>
+              <template is="dom-if" if="[[option.info.inherited_value]]">
+                <span class="inherited">
+                  (Inherited: [[option.info.inherited_value]])
+                </span>
+              </template>
+            </span>
+          </section>
+        </template>
+      </fieldset>
+    </div>
+  </template>
+  <script src="gr-repo-plugin-config.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
new file mode 100644
index 0000000..6d7677e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
@@ -0,0 +1,130 @@
+/**
+ * @license
+ * Copyright (C) 2018 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.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-repo-plugin-config',
+
+    /**
+     * Fired when the plugin config changes.
+     *
+     * @event plugin-config-changed
+     */
+
+    properties: {
+      /** @type {?} */
+      pluginData: Object,
+      /** @type {Array} */
+      _pluginConfigOptions: {
+        type: Array,
+        computed: '_computePluginConfigOptions(pluginData.*)',
+      },
+    },
+
+    behaviors: [
+      Gerrit.RepoPluginConfig,
+    ],
+
+    _computePluginConfigOptions(dataRecord) {
+      if (!dataRecord || !dataRecord.base || !dataRecord.base.config) {
+        return [];
+      }
+      const {config} = dataRecord.base;
+      return Object.keys(config).map(_key => ({_key, info: config[_key]}));
+    },
+
+    _isArray(type) {
+      return type === this.ENTRY_TYPES.ARRAY;
+    },
+
+    _isBoolean(type) {
+      return type === this.ENTRY_TYPES.BOOLEAN;
+    },
+
+    _isList(type) {
+      return type === this.ENTRY_TYPES.LIST;
+    },
+
+    _isString(type) {
+      // Treat numbers like strings for simplicity.
+      return type === this.ENTRY_TYPES.STRING ||
+          type === this.ENTRY_TYPES.INT ||
+          type === this.ENTRY_TYPES.LONG;
+    },
+
+    _computeDisabled(editable) {
+      return editable === 'false';
+    },
+
+    _computeChecked(value) {
+      return JSON.parse(value);
+    },
+
+    _handleStringChange(e) {
+      const el = Polymer.dom(e).localTarget;
+      const _key = el.getAttribute('data-option-key');
+      const configChangeInfo =
+          this._buildConfigChangeInfo(el.value, _key);
+      this._handleChange(configChangeInfo);
+    },
+
+    _handleListChange(e) {
+      const el = Polymer.dom(e).localTarget;
+      const _key = el.getAttribute('data-option-key');
+      const configChangeInfo =
+          this._buildConfigChangeInfo(el.value, _key);
+      this._handleChange(configChangeInfo);
+    },
+
+    _handleBooleanChange(e) {
+      const el = Polymer.dom(e).localTarget;
+      const _key = el.getAttribute('data-option-key');
+      const configChangeInfo =
+          this._buildConfigChangeInfo(JSON.stringify(el.checked), _key);
+      this._handleChange(configChangeInfo);
+    },
+
+    _buildConfigChangeInfo(value, _key) {
+      const info = this.pluginData.config[_key];
+      info.value = value;
+      return {
+        _key,
+        info,
+        notifyPath: `${_key}.value`,
+      };
+    },
+
+    _handleArrayChange({detail}) {
+      this._handleChange(detail);
+    },
+
+    _handleChange({_key, info, notifyPath}) {
+      const {name, config} = this.pluginData;
+
+      /** @type {Object} */
+      const detail = {
+        name,
+        config: Object.assign(config, {[_key]: info}, {}),
+        notifyPath: `${name}.${notifyPath}`,
+      };
+
+      this.dispatchEvent(new CustomEvent(this.PLUGIN_CONFIG_CHANGED,
+          {detail, bubbles: true}));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
new file mode 100644
index 0000000..2af2043
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
@@ -0,0 +1,175 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-repo-plugin-config</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-repo-plugin-config.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-repo-plugin-config></gr-repo-plugin-config>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-repo-plugin-config tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => sandbox.restore());
+
+    test('_computePluginConfigOptions', () => {
+      assert.deepEqual(element._computePluginConfigOptions(), []);
+      assert.deepEqual(element._computePluginConfigOptions({}), []);
+      assert.deepEqual(element._computePluginConfigOptions({base: {}}), []);
+      assert.deepEqual(element._computePluginConfigOptions(
+          {base: {config: {}}}), []);
+      assert.deepEqual(element._computePluginConfigOptions(
+          {base: {config: {testKey: 'testInfo'}}}),
+          [{_key: 'testKey', info: 'testInfo'}]);
+    });
+
+    test('_computeDisabled', () => {
+      assert.isFalse(element._computeDisabled('true'));
+      assert.isTrue(element._computeDisabled('false'));
+    });
+
+    test('_handleChange', () => {
+      const eventStub = sandbox.stub(element, 'dispatchEvent');
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test'}},
+      };
+      element._handleChange({
+        _key: 'plugin',
+        info: {value: 'newTest'},
+        notifyPath: 'plugin.value',
+      });
+
+      assert.isTrue(eventStub.called);
+
+      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', () => {
+      let changeStub;
+      let buildStub;
+
+      setup(() => {
+        changeStub = sandbox.stub(element, '_handleChange');
+        buildStub = sandbox.stub(element, '_buildConfigChangeInfo');
+      });
+
+      test('ARRAY type option', () => {
+        element.pluginData = {
+          name: 'testName',
+          config: {plugin: {value: 'test', type: 'ARRAY'}},
+        };
+        flushAsynchronousOperations();
+
+        const editor = element.$$('gr-plugin-config-array-editor');
+        assert.ok(editor);
+        element._handleArrayChange({detail: 'test'});
+        assert.isTrue(changeStub.called);
+        assert.equal(changeStub.lastCall.args[0], 'test');
+      });
+
+      test('BOOLEAN type option', () => {
+        element.pluginData = {
+          name: 'testName',
+          config: {plugin: {value: 'true', type: 'BOOLEAN'}},
+        };
+        flushAsynchronousOperations();
+
+        const toggle = element.$$('paper-toggle-button');
+        assert.ok(toggle);
+        toggle.click();
+        flushAsynchronousOperations();
+
+        assert.isTrue(buildStub.called);
+        assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
+
+        assert.isTrue(changeStub.called);
+      });
+
+      test('INT/LONG/STRING type option', () => {
+        element.pluginData = {
+          name: 'testName',
+          config: {plugin: {value: 'test', type: 'STRING'}},
+        };
+        flushAsynchronousOperations();
+
+        const input = element.$$('input');
+        assert.ok(input);
+        input.value = 'newTest';
+        input.dispatchEvent(new Event('input'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(buildStub.called);
+        assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+        assert.isTrue(changeStub.called);
+      });
+
+      test('LIST type option', () => {
+        const permitted_values = ['test', 'newTest'];
+        element.pluginData = {
+          name: 'testName',
+          config: {plugin: {value: 'test', type: 'LIST', permitted_values}},
+        };
+        flushAsynchronousOperations();
+
+        const select = element.$$('select');
+        assert.ok(select);
+        select.value = 'newTest';
+        select.dispatchEvent(new Event('change', {bubbles: true}));
+        flushAsynchronousOperations();
+
+        assert.isTrue(buildStub.called);
+        assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+        assert.isTrue(changeStub.called);
+      });
+    });
+
+    test('_buildConfigChangeInfo', () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test'}},
+      };
+      const detail = element._buildConfigChangeInfo('newTest', 'plugin');
+      assert.equal(detail._key, 'plugin');
+      assert.deepEqual(detail.info, {value: 'newTest'});
+      assert.equal(detail.notifyPath, 'plugin.value');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
index 6ce1a09..cd27322 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
@@ -26,7 +26,7 @@
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/gr-subpage-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
-
+<link rel="import" href="../gr-repo-plugin-config/gr-repo-plugin-config.html">
 
 <dom-module id="gr-repo">
   <template>
@@ -37,7 +37,7 @@
         content: ' *';
       }
       .loading,
-      .hideDownload {
+      .hide {
         display: none;
       }
       #loading.loading {
@@ -67,7 +67,7 @@
       </div>
       <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
       <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-        <div id="downloadContent" class$="[[_computeDownloadClass(_schemes)]]">
+        <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
           <h2 id="download">Download</h2>
           <fieldset>
             <gr-download-commands
@@ -346,7 +346,15 @@
                 </span>
               </section>
             </fieldset>
-            <!-- TODO @beckysiegel add plugin config widgets -->
+            <div
+                class$="pluginConfig [[_computeHideClass(_pluginData)]]"
+                on-plugin-config-changed="_handlePluginConfigChanged">
+              <h3>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-tap="_handleSaveRepoConfig"
                 disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]">Save changes</gr-button>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
index 840a529..7a52476 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
@@ -74,6 +74,11 @@
       },
       /** @type {?} */
       _repoConfig: Object,
+      /** @type {?} */
+      _pluginData: {
+        type: Array,
+        computed: '_computePluginData(_repoConfig.plugin_config.*)',
+      },
       _readOnly: {
         type: Boolean,
         value: true,
@@ -118,6 +123,15 @@
       this.fire('title-change', {title: this.repo});
     },
 
+    _computePluginData(configRecord) {
+      if (!configRecord ||
+          !configRecord.base) { return []; }
+
+      const pluginConfig = configRecord.base;
+      return Object.keys(pluginConfig)
+          .map(name => ({name, config: pluginConfig[name]}));
+    },
+
     _loadRepo() {
       if (!this.repo) { return Promise.resolve(); }
 
@@ -173,8 +187,8 @@
       return loading ? 'loading' : '';
     },
 
-    _computeDownloadClass(schemes) {
-      return !schemes || !schemes.length ? 'hideDownload' : '';
+    _computeHideClass(arr) {
+      return !arr || !arr.length ? 'hide' : '';
     },
 
     _loggedInChanged(_loggedIn) {
@@ -246,20 +260,22 @@
       return this.$.restAPI.getLoggedIn();
     },
 
-    _formatRepoConfigForSave(p) {
+    _formatRepoConfigForSave(repoConfig) {
       const configInputObj = {};
-      for (const key in p) {
-        if (p.hasOwnProperty(key)) {
+      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 (typeof p[key] === 'object') {
-            configInputObj[key] = p[key].configured_value;
+          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] = p[key];
+            configInputObj[key] = repoConfig[key];
           }
         }
       }
@@ -323,5 +339,10 @@
     _computeChangesUrl(name) {
       return Gerrit.Nav.getUrlForProjectChanges(name);
     },
+
+    _handlePluginConfigChanged({detail: {name, config, notifyPath}}) {
+      this._repoConfig.plugin_config[name] = config;
+      this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
index d6d4366..c987278 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
@@ -125,6 +125,29 @@
       sandbox.restore();
     });
 
+    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 = sandbox.stub(element, 'notifyPath');
+      element._repoConfig = {plugin_config: {}};
+      element._handlePluginConfigChanged({detail: {
+        name: 'test',
+        config: 'data',
+        notifyPath: 'path',
+      }});
+      flushAsynchronousOperations();
+
+      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');
@@ -136,14 +159,12 @@
     test('download commands visibility', () => {
       element._loading = false;
       flushAsynchronousOperations();
-      assert.isTrue(element.$.downloadContent.classList
-          .contains('hideDownload'));
+      assert.isTrue(element.$.downloadContent.classList.contains('hide'));
       assert.isTrue(getComputedStyle(element.$.downloadContent)
           .display == 'none');
       element._schemesObj = SCHEMES;
       flushAsynchronousOperations();
-      assert.isFalse(element.$.downloadContent.classList
-          .contains('hideDownload'));
+      assert.isFalse(element.$.downloadContent.classList.contains('hide'));
       assert.isFalse(getComputedStyle(element.$.downloadContent)
           .display == 'none');
     });
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
index 21bf649..538bab7 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -55,6 +55,9 @@
       #email {
         margin-bottom: 1em;
       }
+      main section.darkToggle {
+        display: block;
+      }
       .filters p,
       .darkToggle p {
         margin-bottom: 1em;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 4b66a6e..cfd5bac 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -463,10 +463,11 @@
     saveRepoConfig(repo, config, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      const encodeName = encodeURIComponent(repo);
+      const url = `/projects/${encodeURIComponent(repo)}/config`;
+      this._cache.delete(url);
       return this._send({
         method: 'PUT',
-        url: `/projects/${encodeName}/config`,
+        url,
         body: config,
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/config',
diff --git a/polygerrit-ui/app/styles/gr-form-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
index 59b633f..65c1ae3 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.html
+++ b/polygerrit-ui/app/styles/gr-form-styles.html
@@ -29,11 +29,15 @@
       .gr-form-styles h2 {
         margin-bottom: .3em;
       }
+      .gr-form-styles h4 {
+        font-weight: var(--font-weight-bold);
+      }
       .gr-form-styles fieldset {
         border: none;
         margin-bottom: 2em;
       }
       .gr-form-styles section {
+        display: flex;
         margin: .25em 0;
         min-height: 2em;
       }
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index d9e1238..bc705a8 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -44,6 +44,7 @@
     'admin/gr-group-members/gr-group-members_test.html',
     'admin/gr-group/gr-group_test.html',
     'admin/gr-permission/gr-permission_test.html',
+    'admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html',
     'admin/gr-plugin-list/gr-plugin-list_test.html',
     'admin/gr-repo-access/gr-repo-access_test.html',
     'admin/gr-repo-command/gr-repo-command_test.html',
@@ -51,6 +52,7 @@
     'admin/gr-repo-dashboards/gr-repo-dashboards_test.html',
     'admin/gr-repo-detail-list/gr-repo-detail-list_test.html',
     'admin/gr-repo-list/gr-repo-list_test.html',
+    'admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html',
     'admin/gr-repo/gr-repo_test.html',
     'admin/gr-rule-editor/gr-rule-editor_test.html',
     'change-list/gr-change-list-item/gr-change-list-item_test.html',