Handle restAPI errors

The pluginStatus property is added to the CodeOwnersModel class. The
ModelLoader catches all exceptions and setup failed pluginStatus when
a request fails.
If a fail happens, the error is displayed:
* At the top of page
* Near the "Code owners" label

The banner is required due to the following reason: If the code-owners
plugin configuration has a problem, the "Code owners" label can be
hidden (backend doesn't return the label). But we still want to display
a error message, so the code uses banner to show the error.

Change-Id: I0701b269ae32d2727e2749324a664f071eec9c80
diff --git a/ui/code-owners-banner.js b/ui/code-owners-banner.js
new file mode 100644
index 0000000..a44e258
--- /dev/null
+++ b/ui/code-owners-banner.js
@@ -0,0 +1,176 @@
+/**
+ * @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 {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
+import {PluginState} from './code-owners-model.js';
+
+// There are 2 elements in this file:
+// CodeOwnersBanner - visual elements. This element is shown at the top
+// of the page when pluginStatus are changed to failed.
+// The CodeOwnersBanner is added to the 'banner' endpoint. The endpoint
+// is placed at the top level of the gerrit and doesn't provide a current
+// change, so it is impossible to get the pluginStatus directly in the
+// CodeOwnersBanner.
+// To solve this problem, this files provides non-visual
+// CodeOwnersPluginStatusNotifier element. This element is added to the
+// change-view-integration endpoint, so it can track the plugin state.
+// When a plugin state is changed, the CodeOwnersPluginStatusNotifier updates
+// the pluginStatus property of the banner.
+
+// CodeOwnersBanner and CodeOwnersPluginStatusNotifier can be attached and
+// detached in any order. The following piece of code ensures that these
+// banner and notifier are always connected correctly. The code assumes,
+// that max one banner and one notifier can be connected at any time.
+let activeBanner = undefined;
+let activeStatusNotifier = undefined;
+
+function setActiveBanner(banner) {
+  // banner is null when CodeOwnersBanner has been disconnected
+  activeBanner = banner;
+  if (activeStatusNotifier) {
+    activeStatusNotifier.banner = banner;
+  }
+}
+
+function setActiveStatusNotifier(notifier) {
+  // notifier is null when CodeOwnersBanner has been disconnected
+  if (activeStatusNotifier) {
+    if (activeStatusNotifier.banner) {
+      activeStatusNotifier.banner.pluginStatus = undefined;
+    }
+    activeStatusNotifier.banner = undefined;
+  }
+  activeStatusNotifier = notifier;
+  if (activeStatusNotifier) {
+    activeStatusNotifier.banner = activeBanner;
+  }
+}
+
+export class CodeOwnersBanner extends Polymer.Element {
+  static get is() { return 'gr-code-owners-banner'; }
+
+  static get template() {
+    return Polymer.html`
+      <style include="shared-styles">
+        :host {
+          display: block;
+          overflow: hidden;
+          background: red;
+        }
+        .text {
+          color: white;
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+          margin-left: var(--spacing-l);
+        }
+      </style>
+      <span class="text">Error: Code-owners plugin has failed</span>
+      <gr-button link on-click="_showFailDetails">
+        Details
+      </gr-button>
+    `;
+  }
+
+  static get properties() {
+    return {
+      // @internal attributes
+      hidden: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+        computed: '_computeHidden(pluginStatus)',
+      },
+      pluginStatus: {
+        type: Object,
+      },
+    };
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    setActiveBanner(this);
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    setActiveBanner(undefined);
+  }
+
+  _computeHidden(pluginStatus) {
+    return !pluginStatus || pluginStatus.state !== PluginState.Failed;
+  }
+
+  _showFailDetails() {
+    showPluginFailedMessage(this, this.pluginStatus);
+  }
+}
+
+customElements.define(CodeOwnersBanner.is, CodeOwnersBanner);
+
+export class CodeOwnersPluginStatusNotifier extends
+  CodeOwnersModelMixin(Polymer.Element) {
+  static get is() {
+    return 'owners-plugin-status-notifier';
+  }
+
+  static get properties() {
+    return {
+      banner: {
+        type: Object,
+      },
+    };
+  }
+
+  static get observers() {
+    return [
+      '_pluginStatusOrBannerChanged(model.pluginStatus, banner)',
+    ];
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    setActiveStatusNotifier(this);
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    setActiveStatusNotifier(undefined);
+  }
+
+  _pluginStatusOrBannerChanged(status, banner) {
+    if (!banner) return;
+    banner.pluginStatus = status;
+  }
+
+  _loadDataAfterStateChanged() {
+    this.modelLoader.loadPluginStatus();
+  }
+}
+
+customElements.define(CodeOwnersPluginStatusNotifier.is,
+    CodeOwnersPluginStatusNotifier);
+
+export function showPluginFailedMessage(sourceEl, pluginStatus) {
+  sourceEl.dispatchEvent(
+      new CustomEvent('show-error', {
+        detail: {message: pluginStatus.failedMessage},
+        composed: true,
+        bubbles: true,
+      })
+  );
+}
diff --git a/ui/code-owners-model-loader.js b/ui/code-owners-model-loader.js
index 2074fcb..2cd9095 100644
--- a/ui/code-owners-model-loader.js
+++ b/ui/code-owners-model-loader.js
@@ -35,7 +35,13 @@
   async _loadProperty(propertyName, propertyLoader, propertySetter) {
     // Load property only if it was not already loaded
     if (this.ownersModel[propertyName] !== undefined) return;
-    const newValue = await propertyLoader();
+    let newValue;
+    try {
+      newValue = await propertyLoader();
+    } catch (e) {
+      this.ownersModel.setPluginFailed(e.message);
+      return;
+    }
     // It is possible, that several requests is made in parallel.
     // Store only the first result and discard all other results.
     // (also, due to the CodeOwnersCacheApi all result must be identical)
@@ -64,10 +70,10 @@
     );
   }
 
-  async loadIsCodeOwnerEnabled() {
-    await this._loadProperty('isCodeOwnerEnabled',
+  async loadPluginStatus() {
+    await this._loadProperty('pluginStatus',
         () => this.ownersService.isCodeOwnerEnabled(),
-        value => this.ownersModel.setIsCodeOwnerEnabled(value)
+        value => this.ownersModel.setPluginEnabled(value)
     );
   }
 
@@ -84,14 +90,26 @@
         !== SuggestionsState.NotLoaded) return;
 
     this.ownersModel.setSuggestionsState(SuggestionsState.Loading);
-    const suggestedOwners = await this.ownersService.getSuggestedOwners();
+    let suggestedOwners;
+    try {
+      suggestedOwners = await this.ownersService.getSuggestedOwners();
+    } catch (e) {
+      this.ownersModel.setSuggestionsState(SuggestionsState.LoadFailed);
+      this.ownersModel.setPluginFailed(e.message);
+      return;
+    }
     this.ownersModel.setSuggestions(suggestedOwners.suggestions);
     this.ownersModel.setSuggestionsState(SuggestionsState.Loaded);
   }
 
   async updateLoadSuggestionsProgress() {
-    const suggestedOwners =
-        await this.ownersService.getSuggestedOwnersProgress();
+    let suggestedOwners;
+    try {
+      suggestedOwners = await this.ownersService.getSuggestedOwnersProgress();
+    } catch {
+      // Ignore any error, keep progress unchanged.
+      return;
+    }
     this.ownersModel.setSuggestionsLoadProgress(suggestedOwners.progress);
     this.ownersModel.setSuggestions(suggestedOwners.suggestions);
   }
diff --git a/ui/code-owners-model.js b/ui/code-owners-model.js
index 4c63419..cbd9bd2 100644
--- a/ui/code-owners-model.js
+++ b/ui/code-owners-model.js
@@ -19,6 +19,13 @@
   NotLoaded: 'NotLoaded',
   Loaded: 'Loaded',
   Loading: 'Loading',
+  LoadFailed: 'LoadFailed',
+};
+
+export const PluginState = {
+  Enabled: 'Enabled',
+  Disabled: 'Disabled',
+  Failed: 'Failed',
 };
 
 /**
@@ -51,6 +58,7 @@
     this.suggestionsState = SuggestionsState.NotLoaded;
     this.suggestionsLoadProgress = undefined;
     this.showSuggestions = false;
+    this.pluginStatus = undefined;
   }
 
   setBranchConfig(config) {
@@ -107,6 +115,31 @@
     this._firePropertyChanged('showSuggestions');
   }
 
+  setPluginEnabled(enabled) {
+    this._setPluginStatus({state: enabled ?
+      PluginState.Enabled : PluginState.Disabled});
+  }
+
+  setPluginFailed(failedMessage) {
+    this._setPluginStatus({state: PluginState.Failed, failedMessage});
+  }
+
+  _setPluginStatus(status) {
+    if (this._arePluginStatusesEqual(this.pluginStatus, status)) return;
+    this.pluginStatus = status;
+    this._firePropertyChanged('pluginStatus');
+  }
+
+  _arePluginStatusesEqual(status1, status2) {
+    if (status1 === undefined || status2 === undefined) {
+      return status1 === status2;
+    }
+    if (status1.state !== status2.state) return false;
+    return status1.state === PluginState.Failed ?
+      status1.failedMessage === status2.failedMessage :
+      true;
+  }
+
   _firePropertyChanged(propertyName) {
     this.dispatchEvent(new CustomEvent('model-property-changed', {
       detail: {
diff --git a/ui/code-owners-service.js b/ui/code-owners-service.js
index 9b670b9..86fbfcc 100644
--- a/ui/code-owners-service.js
+++ b/ui/code-owners-service.js
@@ -302,8 +302,16 @@
    * Prefetch data
    */
   async prefetch() {
-    this.codeOwnerCacheApi.getAccount();
-    this.getStatus();
+    try {
+      await Promise.all([
+        this.codeOwnerCacheApi.getAccount(),
+        this.getStatus(),
+      ]);
+    } catch {
+      // Ignore any errors during prefetch.
+      // The same call from a different place throws the same exception
+      // again. The CodeOwnerService is not responsible for error processing.
+    }
   }
 
   /**
@@ -567,7 +575,7 @@
       return false;
     }
     const config = await this.codeOwnerCacheApi.getBranchConfig();
-    return !(config.status && config.status.disabled);
+    return config && !(config.status && config.status.disabled);
   }
 
   static getOwnerService(restApi, change) {
diff --git a/ui/owner-requirement.js b/ui/owner-requirement.js
index c9d9cb8..230577c 100644
--- a/ui/owner-requirement.js
+++ b/ui/owner-requirement.js
@@ -17,6 +17,8 @@
 
 import {OwnerStatus} from './code-owners-service.js';
 import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
+import {showPluginFailedMessage} from './code-owners-banner.js';
+import {PluginState} from './code-owners-model.js';
 
 /**
  * Owner requirement control for `submit-requirement-item-code-owners` endpoint.
@@ -62,18 +64,26 @@
           Loading status ...
         </p>
         <template is="dom-if" if="[[!_isLoading]]">
-          <span>[[_computeStatusText(_statusCount, _isOverriden)]]</span>
-          <template is="dom-if" if="[[_overrideInfoUrl]]">
-            <a on-click="_reportDocClick" href="[[_overrideInfoUrl]]"
-              target="_blank">
-              <iron-icon icon="gr-icons:help-outline"
-                title="Documentation for overriding code owners"></iron-icon>
-            </a>
+          <template is="dom-if" if="[[!_pluginFailed(model.pluginStatus)]]">  
+            <span>[[_computeStatusText(_statusCount, _isOverriden)]]</span>
+            <template is="dom-if" if="[[_overrideInfoUrl]]">
+              <a on-click="_reportDocClick" href="[[_overrideInfoUrl]]"
+                target="_blank">
+                <iron-icon icon="gr-icons:help-outline"
+                  title="Documentation for overriding code owners"></iron-icon>
+              </a>
+            </template>
+            <template is="dom-if" if="[[!_allApproved]]">
+              <gr-button link on-click="_openReplyDialog">
+              Suggest owners
+            </gr-button>
+            </template>
           </template>
-          <template is="dom-if" if="[[!_allApproved]]">
-            <gr-button link on-click="_openReplyDialog">
-            Suggest owners
-          </gr-button>
+          <template is="dom-if" if="[[_pluginFailed(model.pluginStatus)]]">
+            <span>Code-owners plugin has failed</span>
+            <gr-button link on-click="_showFailDetails">
+              Details
+            </gr-button>
           </template>
         </template>
       `;
@@ -85,7 +95,7 @@
       _isLoading: {
         type: Boolean,
         computed: '_computeIsLoading(model.branchConfig, model.status, '
-            + 'model.userRole)',
+            + 'model.userRole, model.pluginStatus)',
       },
       _allApproved: {
         type: Boolean,
@@ -116,10 +126,17 @@
     this.modelLoader.loadUserRole();
   }
 
-  _computeIsLoading(branchConfig, status, userRole) {
+  _computeIsLoading(branchConfig, status, userRole, pluginStatus) {
+    if (this._pluginFailed(pluginStatus)) {
+      return false;
+    }
     return !branchConfig || !status || !userRole;
   }
 
+  _pluginFailed(pluginStatus) {
+    return pluginStatus && pluginStatus.state === PluginState.Failed;
+  }
+
   _onStatusChanged(status, userRole) {
     if (!status || !userRole) {
       this._statusCount = undefined;
@@ -160,7 +177,7 @@
   }
 
   _computeAllApproved(statusCount) {
-    return statusCount.missing === 0
+    return statusCount && statusCount.missing === 0
             && statusCount.pending === 0;
   }
 
@@ -225,6 +242,10 @@
     this.reporting.reportInteraction('suggest-owners-from-submit-requirement',
         {user_role: this.model.userRole});
   }
+
+  _showFailDetails() {
+    showPluginFailedMessage(this, this.model.pluginStatus);
+  }
 }
 
 customElements.define(OwnerRequirementValue.is, OwnerRequirementValue);
diff --git a/ui/plugin.js b/ui/plugin.js
index 1a278dd..40bc4e5 100644
--- a/ui/plugin.js
+++ b/ui/plugin.js
@@ -19,11 +19,21 @@
 import {OwnerStatusColumnContent, OwnerStatusColumnHeader} from './owner-status-column.js';
 import {OwnerRequirementValue} from './owner-requirement.js';
 import {SuggestOwnersTrigger} from './suggest-owners-trigger.js';
+import {CodeOwnersBanner, CodeOwnersPluginStatusNotifier} from './code-owners-banner.js';
 
 Gerrit.install(plugin => {
   const restApi = plugin.restApi();
   const reporting = plugin.reporting();
 
+  plugin.registerCustomComponent('banner', CodeOwnersBanner.is);
+
+  plugin.registerCustomComponent(
+      'change-view-integration', CodeOwnersPluginStatusNotifier.is)
+      .onAttached(view => {
+        view.restApi = restApi;
+        view.reporting = reporting;
+      });
+
   // owner status column / rows for file list
   plugin.registerDynamicCustomComponent(
       'change-view-file-list-header-prepend', OwnerStatusColumnHeader.is)
@@ -61,4 +71,4 @@
         view.restApi = restApi;
         view.reporting = reporting;
       });
-});
\ No newline at end of file
+});
diff --git a/ui/suggest-owners-trigger.js b/ui/suggest-owners-trigger.js
index 8e0e293..544c956 100644
--- a/ui/suggest-owners-trigger.js
+++ b/ui/suggest-owners-trigger.js
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
+import {PluginState} from './code-owners-model.js';
 
 export class SuggestOwnersTrigger extends
   CodeOwnersModelMixin(Polymer.Element) {
@@ -26,7 +27,7 @@
     return {
       hidden: {
         type: Boolean,
-        computed: '_computeHidden(model.isCodeOwnerEnabled,' +
+        computed: '_computeHidden(model.pluginStatus,' +
             'model.areAllFilesApproved, model.userRole)',
         reflectToAttribute: true,
       },
@@ -68,17 +69,17 @@
   loadPropertiesAfterModelChanged() {
     super.loadPropertiesAfterModelChanged();
     this.modelLoader.loadUserRole();
-    this.modelLoader.loadIsCodeOwnerEnabled();
+    this.modelLoader.loadPluginStatus();
     this.modelLoader.loadAreAllFilesApproved();
   }
 
-  _computeHidden(enabled, allFilesApproved, userRole) {
-    if (enabled === undefined ||
+  _computeHidden(pluginStatus, allFilesApproved, userRole) {
+    if (pluginStatus === undefined ||
         allFilesApproved === undefined ||
         userRole === undefined) {
       return true;
     }
-    if (enabled) {
+    if (pluginStatus.state === PluginState.Enabled) {
       return allFilesApproved;
     } else {
       return true;
diff --git a/ui/suggest-owners.js b/ui/suggest-owners.js
index 068b933..e0bf8a8 100644
--- a/ui/suggest-owners.js
+++ b/ui/suggest-owners.js
@@ -257,6 +257,7 @@
             <template is="dom-if" if="[[suggestion.error]]">
               <div class="fetch-error-content">
                 [[suggestion.error]]
+                <a on-click="_showErrorDetails"
               </div>
             </template>
             <template is="dom-if" if="[[!suggestion.error]]">