Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js
new file mode 100644
index 0000000..5a3e42c
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js
@@ -0,0 +1,59 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+import {determineSloStatus} from './slo-rules.js';
+
+/** @typedef {import('./slo-rules.js').SloStatus} SloStatus */
+
+/**
+ * `<mr-issue-slo>`
+ *
+ * A widget for showing the given issue's SLO status.
+ */
+export class MrIssueSlo extends LitElement {
+  /** @override */
+  static get styles() {
+    return css``;
+  }
+
+  /** @override */
+  render() {
+    const sloStatus = this._determineSloStatus();
+    if (!sloStatus) {
+      return html`N/A`;
+    }
+    if (!sloStatus.target) {
+      return html`Done`;
+    }
+    return html`
+      <chops-timestamp .timestamp=${sloStatus.target} short></chops-timestamp>`;
+  }
+
+  /**
+   * Wrapper around slo-rules.js determineSloStatus to allow tests to override
+   * the return value.
+   * @private
+   * @return {SloStatus}
+   */
+  _determineSloStatus() {
+    return this.issue ? determineSloStatus(this.issue) : null;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issue: {type: Object},
+    };
+  }
+  /** @override */
+  constructor() {
+    super();
+    /** @type {Issue} */
+    this.issue;
+  }
+}
+customElements.define('mr-issue-slo', MrIssueSlo);
diff --git a/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.js b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.js
new file mode 100644
index 0000000..28d23eb
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.js
@@ -0,0 +1,54 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrIssueSlo} from './mr-issue-slo.js';
+
+
+let element;
+
+describe('mr-issue-slo', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-slo');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueSlo);
+  });
+
+  it('handles ineligible issues', async () => {
+    element._determineSloStatus = () => {
+      return null;
+    };
+    element.issue = {};
+    await element.updateComplete;
+    assert.equal(element.shadowRoot.textContent, 'N/A');
+  });
+
+  it('handles issues that have completed the SLO criteria', async () => {
+    element._determineSloStatus = () => {
+      return {target: null};
+    };
+    element.issue = {};
+    await element.updateComplete;
+    assert.equal(element.shadowRoot.textContent, 'Done');
+  });
+
+  it('handles issues that have not completed the SLO criteria', async () => {
+    element._determineSloStatus = () => {
+      return {target: 1234};
+    };
+    element.issue = {};
+    await element.updateComplete;
+    const timestampElement =
+        element.shadowRoot.querySelector('chops-timestamp');
+
+    assert.equal(timestampElement.timestamp, 1234);
+  });
+});
diff --git a/static_src/elements/framework/mr-issue-slo/slo-rules.js b/static_src/elements/framework/mr-issue-slo/slo-rules.js
new file mode 100644
index 0000000..e351ae0
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/slo-rules.js
@@ -0,0 +1,195 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Determining Issues' statuses relative to SLO rules.
+ *
+ * See go/monorail-slo-v0 for more info.
+ */
+
+/**
+ * A rule determining the compliance of an issue with regard to an SLO.
+ * @typedef {Object} SloRule
+ * @property {function(Issue): SloStatus} statusFunction
+ */
+
+/**
+ * Potential statuses of an issue relative to an SLO's completion criteria.
+ * @enum {string}
+ */
+export const SloCompletionStatus = {
+  /** The completion criteria for the SloRule have not been satisfied. */
+  INCOMPLETE: 'INCOMPLETE',
+  /** The completion criteria for the SloRule have been satisfied. */
+  COMPLETE: 'COMPLETE',
+};
+
+/**
+ * The status of an issue with regard to an SloRule.
+ * @typedef {Object} SloStatus
+ * @property {SloRule} rule The rule that generated this status.
+ * @property {Date} target The time the Issue must move to completion, or null
+ *     if the issue has already moved to completion.
+ * @property {SloCompletionStatus} completion Issue's completion status.
+ */
+
+/**
+ * Chrome OS Software's SLO for issue closure (go/chromeos-software-bug-slos).
+ *
+ * Implementation based on the queries defined in Sheriffbot
+ * https://chrome-internal.googlesource.com/infra/infra_internal/+/refs/heads/main/appengine/sheriffbot/src/sheriffbot/bug_slo_daily_queries.py
+ *
+ * @const {SloRule}
+ * @private Only visible for testing.
+ */
+export const _CROS_CLOSURE_SLO = {
+  statusFunction: (issue) => {
+    if (!_isCrosClosureEligible(issue)) {
+      return null;
+    }
+
+    const pri = getPriFromIssue(issue);
+    const daysToClose = _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY[pri];
+
+    if (!daysToClose) {
+      // No applicable SLO found issues with this priority.
+      return null;
+    }
+    // Return a complete status for closed issues.
+    if (issue.statusRef && !issue.statusRef.meansOpen) {
+      return {
+        rule: _CROS_CLOSURE_SLO,
+        target: null,
+        completion: SloCompletionStatus.COMPLETE};
+    }
+
+    // Set the target based on the opening and the daysToClose.
+    const target = new Date(issue.openedTimestamp * 1000);
+    target.setDate(target.getDate() + daysToClose);
+    return {
+      rule: _CROS_CLOSURE_SLO,
+      target: target,
+      completion: SloCompletionStatus.INCOMPLETE};
+  },
+};
+
+/**
+ * @param {Issue} issue
+ * @return {string?} the pri's value, if found.
+ */
+const getPriFromIssue = (issue) => {
+  for (const fv of issue.fieldValues) {
+    if (fv.fieldRef.fieldName === 'Pri') {
+      return fv.value;
+    }
+  }
+};
+
+/**
+ * The number of days (since the issue was opened) allowed for it to be fixed.
+ * @private Only visible for testing.
+ */
+export const _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY = Object.freeze({
+  '1': 42,
+});
+
+// https://chrome-internal.googlesource.com/infra/infra_internal/+/refs/heads/main/appengine/sheriffbot/src/sheriffbot/bug_slo_daily_queries.py#97
+const CROS_ELIGIBLE_COMPONENT_PATHS = new Set([
+  'OS>Systems>CrashReporting',
+  'OS>Systems>Displays',
+  'OS>Systems>Feedback',
+  'OS>Systems>HaTS',
+  'OS>Systems>Input',
+  'OS>Systems>Input>Keyboard',
+  'OS>Systems>Input>Mouse',
+  'OS>Systems>Input>Shortcuts',
+  'OS>Systems>Input>Touch',
+  'OS>Systems>Metrics',
+  'OS>Systems>Multidevice',
+  'OS>Systems>Multidevice>Messages',
+  'OS>Systems>Multidevice>SmartLock',
+  'OS>Systems>Multidevice>Tethering',
+  'OS>Systems>Network>Bluetooth',
+  'OS>Systems>Network>Cellular',
+  'OS>Systems>Network>VPN',
+  'OS>Systems>Network>WiFi',
+  'OS>Systems>Printing',
+  'OS>Systems>Settings',
+  'OS>Systems>Spellcheck',
+  'OS>Systems>Update',
+  'OS>Systems>Wallpaper',
+  'OS>Systems>WirelessCharging',
+  'Platform>Apps>Feedback',
+  'UI>Shell>Networking',
+]);
+
+/**
+ * Determines if an issue is eligible for _CROS_CLOSURE_SLO.
+ * @param {Issue} issue
+ * @return {boolean}
+ * @private Only visible for testing.
+ */
+export const _isCrosClosureEligible = (issue) => {
+  // If at least one component applies, continue.
+  const hasEligibleComponent = issue.componentRefs.some(
+      (component) => CROS_ELIGIBLE_COMPONENT_PATHS.has(component.path));
+  if (!hasEligibleComponent) {
+    return false;
+  }
+
+  let priority = null;
+  let hasMilestone = false;
+  for (const fv of issue.fieldValues) {
+    if (fv.fieldRef.fieldName === 'Type') {
+      // These types don't apply.
+      if (fv.value === 'Feature' || fv.value === 'FLT-Launch' ||
+      fv.value === 'Postmortem-Followup' || fv.value === 'Design-Review') {
+        return false;
+      }
+    }
+    if (fv.fieldRef.fieldName === 'Pri') {
+      priority = fv.value;
+    }
+    if (fv.fieldRef.fieldName === 'M') {
+      hasMilestone = true;
+    }
+  }
+  // P1 issues with milestones don't apply.
+  if (priority === '1' && hasMilestone) {
+    return false;
+  }
+  // Issues with the ChromeOS_No_SLO label don't apply.
+  for (const labelRef of issue.labelRefs) {
+    if (labelRef.label === 'ChromeOS_No_SLO') {
+      return false;
+    }
+  }
+  return true;
+};
+
+/**
+ * Active SLO Rules.
+ * @const {Array<SloRule>}
+ */
+const SLO_RULES = [_CROS_CLOSURE_SLO];
+
+/**
+ * Determines the SloStatus for the given issue.
+ * @param {Issue} issue The issue to check.
+ * @return {SloStatus} The status of the issue, or null if no rules apply.
+ */
+export const determineSloStatus = (issue) => {
+  try {
+    for (const rule of SLO_RULES) {
+      const status = rule.statusFunction(issue);
+      if (status) {
+        return status;
+      }
+    }
+  } catch (error) {
+    // Don't bubble up any errors in SLO_RULES functions, which might sometimes
+    // be written/updated by client teams.
+  }
+  return null;
+};
diff --git a/static_src/elements/framework/mr-issue-slo/slo-rules.test.js b/static_src/elements/framework/mr-issue-slo/slo-rules.test.js
new file mode 100644
index 0000000..a48e5e2
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/slo-rules.test.js
@@ -0,0 +1,152 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {_CROS_CLOSURE_SLO, _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY,
+  _isCrosClosureEligible, SloCompletionStatus, determineSloStatus}
+  from './slo-rules.js';
+
+const P1_FIELD_VALUE = Object.freeze({
+  fieldRef: {
+    fieldId: 1,
+    fieldName: 'Pri',
+    type: 'ENUM_TYPE',
+  },
+  value: '1'});
+
+// TODO(crbug.com/monorail/7843): Separate testing of determineSloStatus from
+// testing of specific SLO Rules. Add testing for a rule that throws an error.
+describe('determineSloStatus', () => {
+  it('returns null for ineligible issues', () => {
+    const ineligibleIssue = {
+      componentRefs: [{path: 'Some>Other>Component'}],
+      fieldValues: [P1_FIELD_VALUE],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+    };
+    assert.isNull(determineSloStatus(ineligibleIssue));
+  });
+
+  it('returns null for eligible issues without defined priority', () => {
+    const ineligibleIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+    };
+    assert.isNull(determineSloStatus(ineligibleIssue));
+  });
+
+  it('returns SloStatus with target for incomplete eligible issues', () => {
+    const openedTimestamp = 1412362587;
+    const eligibleIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [P1_FIELD_VALUE],
+      labelRefs: [],
+      localId: 1,
+      openedTimestamp: openedTimestamp,
+      projectName: 'x',
+    };
+    const status = determineSloStatus(eligibleIssue);
+
+    const expectedTarget = new Date(openedTimestamp * 1000);
+    expectedTarget.setDate(
+        expectedTarget.getDate() + _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY['1']);
+
+    assert.equal(status.target.valueOf(), expectedTarget.valueOf());
+    assert.equal(status.completion, SloCompletionStatus.INCOMPLETE);
+    assert.equal(status.rule, _CROS_CLOSURE_SLO);
+  });
+
+  it('returns SloStatus without target for complete eligible issues', () => {
+    const eligibleIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [P1_FIELD_VALUE],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+      statusRef: {status: 'Closed', meansOpen: false},
+    };
+    const status = determineSloStatus(eligibleIssue);
+    assert.isNull(status.target);
+    assert.equal(status.completion, SloCompletionStatus.COMPLETE);
+    assert.equal(status.rule, _CROS_CLOSURE_SLO);
+  });
+});
+
+describe('_isCrosClosureEligible', () => {
+  let crosIssue;
+  beforeEach(() => {
+    crosIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+    };
+  });
+
+  it('returns true when eligible', () => {
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns true if at least one eligible component', () => {
+    crosIssue.componentRefs.push({path: 'Some>Other>Component'});
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for issues in wrong component', () => {
+    crosIssue.componentRefs = [{path: 'Some>Other>Component'}];
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for Feature', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'Feature'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for FLT-Launch', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'FLT-Launch'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for Postmortem-Followup', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'Postmortem-Followup'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for Design-Review', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'Design-Review'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns true for other types', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'type'}, value: 'Any-Other-Type'});
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for p1 with milestone', () => {
+    crosIssue.fieldValues.push(P1_FIELD_VALUE);
+    crosIssue.fieldValues.push({fieldRef: {fieldName: 'M'}, value: 'any'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns true for p1 without milestone', () => {
+    crosIssue.fieldValues.push(P1_FIELD_VALUE);
+    crosIssue.fieldValues.push({fieldRef: {fieldName: 'Other'}, value: 'any'});
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for ChromeOS_No_SLO label', () => {
+    crosIssue.labelRefs.push({label: 'ChromeOS_No_SLO'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+});