[task scheduler] Add skip-tasks-sk element

Change-Id: If7f824ca91be014fb4b02ba94019410282ed2421
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/328908
Reviewed-by: Weston Tracey <westont@google.com>
Commit-Queue: Eric Boren <borenet@google.com>
diff --git a/task_scheduler/modules/rpc-mock/fake-data.ts b/task_scheduler/modules/rpc-mock/fake-data.ts
index 5e846dc..0078cd8 100644
--- a/task_scheduler/modules/rpc-mock/fake-data.ts
+++ b/task_scheduler/modules/rpc-mock/fake-data.ts
@@ -1,5 +1,5 @@
 import { Job, Task } from '../rpc';
-import { TaskStatus, JobStatus, RepoState } from '../rpc/rpc';
+import { TaskStatus, JobStatus, RepoState, SkipTaskRule } from '../rpc/rpc';
 
 export const job1ID = 'aYwjrLWysQRUW2lGFQvR';
 export const job2ID = 'bYwjrLWysQRUW2lGFQvX';
@@ -379,3 +379,25 @@
     },
   ],
 };
+
+export const skipRule1: SkipTaskRule = {
+  addedBy: 'you@google.com',
+  taskSpecPatterns: ['Test-.*', 'Perf-.*'],
+  commits: ['abc123'],
+  description: 'Skip all test and perf tasks at abc123',
+  name: 'No test/perf @ abc',
+};
+
+export const skipRule2: SkipTaskRule = {
+  addedBy: 'me@google.com',
+  commits: ['def456'],
+  description: 'Skip everything at def456',
+  name: 'def456 is bad',
+};
+
+export const skipRule3: SkipTaskRule = {
+  addedBy: 'you@google.com',
+  taskSpecPatterns: ['BadTask'],
+  description: 'Skip all BadTasks at every commit',
+  name: 'BadTask is bad!',
+};
diff --git a/task_scheduler/modules/rpc-mock/index.ts b/task_scheduler/modules/rpc-mock/index.ts
index d6ebad5..5fd1599 100644
--- a/task_scheduler/modules/rpc-mock/index.ts
+++ b/task_scheduler/modules/rpc-mock/index.ts
@@ -21,8 +21,19 @@
   TriggerJobsRequest,
   TriggerJobsResponse,
 } from '../rpc';
-import { job1, task0, task1, task2, task3, task4, job2 } from './fake-data';
-import { JobStatus } from '../rpc/rpc';
+import {
+  job1,
+  task0,
+  task1,
+  task2,
+  task3,
+  task4,
+  job2,
+  skipRule1,
+  skipRule2,
+  skipRule3,
+} from './fake-data';
+import { JobStatus, SkipTaskRule } from '../rpc/rpc';
 
 export * from './fake-data';
 
@@ -43,6 +54,7 @@
     [task4.id]: task4,
   };
   private jobID: number = 0;
+  private skipRules = [skipRule1, skipRule2, skipRule3];
 
   triggerJobs(
     triggerJobsRequest: TriggerJobsRequest
@@ -122,22 +134,26 @@
   getSkipTaskRules(
     getSkipTaskRulesRequest: GetSkipTaskRulesRequest
   ): Promise<GetSkipTaskRulesResponse> {
-    return new Promise((_, reject) => {
-      reject('not implemented');
-    });
+    return Promise.resolve({ rules: this.skipRules.slice() });
   }
   addSkipTaskRule(
     addSkipTaskRuleRequest: AddSkipTaskRuleRequest
   ): Promise<AddSkipTaskRuleResponse> {
-    return new Promise((_, reject) => {
-      reject('not implemented');
+    this.skipRules.push({
+      addedBy: 'you@google.com',
+      taskSpecPatterns: addSkipTaskRuleRequest.taskSpecPatterns,
+      commits: addSkipTaskRuleRequest.commits,
+      description: addSkipTaskRuleRequest.description,
+      name: addSkipTaskRuleRequest.name,
     });
+    return Promise.resolve({ rules: this.skipRules.slice() });
   }
   deleteSkipTaskRule(
     deleteSkipTaskRuleRequest: DeleteSkipTaskRuleRequest
   ): Promise<DeleteSkipTaskRuleResponse> {
-    return new Promise((_, reject) => {
-      reject('not implemented');
-    });
+    this.skipRules = this.skipRules.filter(
+      (rule: SkipTaskRule) => rule.name != deleteSkipTaskRuleRequest.id
+    );
+    return Promise.resolve({ rules: this.skipRules.slice() });
   }
 }
diff --git a/task_scheduler/modules/skip-tasks-sk/index.ts b/task_scheduler/modules/skip-tasks-sk/index.ts
new file mode 100644
index 0000000..cc73e13
--- /dev/null
+++ b/task_scheduler/modules/skip-tasks-sk/index.ts
@@ -0,0 +1,2 @@
+import './skip-tasks-sk';
+import './skip-tasks-sk.scss';
diff --git a/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk-demo.html b/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk-demo.html
new file mode 100644
index 0000000..71a0af3
--- /dev/null
+++ b/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk-demo.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>skip-tasks-sk</title>
+  <meta charset="utf-8" />
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body>
+  <h1>skip-tasks-sk</h1>
+  <skip-tasks-sk></skip-tasks-sk>
+
+  <h2>Events</h2>
+  <pre id=events></pre>
+</body>
+</html>
diff --git a/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk-demo.ts b/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk-demo.ts
new file mode 100644
index 0000000..6b04e67
--- /dev/null
+++ b/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk-demo.ts
@@ -0,0 +1,6 @@
+import './index';
+import { SkipTasksSk } from './skip-tasks-sk';
+import { FakeTaskSchedulerService } from '../rpc-mock';
+
+const ele = <SkipTasksSk>document.querySelector('skip-tasks-sk')!;
+ele.rpc = new FakeTaskSchedulerService();
diff --git a/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk.scss b/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk.scss
new file mode 100644
index 0000000..7f7131d
--- /dev/null
+++ b/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk.scss
@@ -0,0 +1,16 @@
+skip-tasks-sk {
+  .grid {
+    display: grid;
+    grid-template-columns: auto auto;
+  }
+  button {
+    background: inherit;
+    border: none;
+    cursor: pointer;
+  }
+
+  table.new-rule tr {
+    background: inherit;
+    border: none;
+  }
+}
diff --git a/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk.ts b/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk.ts
new file mode 100644
index 0000000..48af0fd
--- /dev/null
+++ b/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk.ts
@@ -0,0 +1,222 @@
+/**
+ * @module modules/skip-tasks-sk
+ * @description <h2><code>skip-tasks-sk</code></h2>
+ *
+ * Provides UI for manipulating rules to prevent triggering of matching tasks.
+ */
+import { define } from 'elements-sk/define';
+import { html } from 'lit-html';
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+import '../../../infra-sk/modules/multi-input-sk';
+import { MultiInputSk } from '../../../infra-sk/modules/multi-input-sk/multi-input-sk';
+import {
+  AddSkipTaskRuleResponse,
+  TaskSchedulerService,
+  SkipTaskRule,
+  GetSkipTaskRulesResponse,
+  DeleteSkipTaskRuleResponse,
+} from '../rpc';
+import { $$ } from 'common-sk/modules/dom';
+import 'elements-sk/icon/add-icon-sk';
+import 'elements-sk/icon/delete-icon-sk';
+import 'elements-sk/styles/buttons';
+import 'elements-sk/styles/table';
+
+export class SkipTasksSk extends ElementSk {
+  private static template = (ele: SkipTasksSk) => html`
+    ${
+      ele.rules
+        ? html`
+            <table>
+              <tr>
+                <th><!-- delete button--></th>
+                <th>Name</th>
+                <th>Added by</th>
+                <th>TaskSpec Patterns</th>
+                <th>Commits</th>
+                <th>Description</th>
+              </tr>
+              ${ele.rules.map(
+                (rule) => html`
+                  <tr>
+                    <td>
+                      <button @click="${() => ele.deleteRule(rule)}">
+                        <delete-icon-sk></delete-icon-sk>
+                      </button>
+                    </td>
+                    <td>${rule.name}</td>
+                    <td>${rule.addedBy}</td>
+                    <td>
+                      ${rule.taskSpecPatterns?.map(
+                        (pattern) => html`
+                          <div class="task_spec_pattern">${pattern}</div>
+                        `
+                      )}
+                    </td>
+                    <td>
+                      ${rule.commits?.map(
+                        (commit) => html` <div class="commit">${commit}</div> `
+                      )}
+                    </td>
+                    <td>${rule.description}</td>
+                  </tr>
+                `
+              )}
+            </table>
+          `
+        : html``
+    }
+    <button class="secondary-container-themes-sk" @click="${() => {
+      $$<HTMLDialogElement>('dialog', ele)!.showModal();
+    }}">
+      <add-icon-sk></add-icon-sk>Add Rule
+    </button>
+    <dialog>
+      <h2>New Rule</h2>
+      <table class="new-rule">
+        <tr>
+          <td>
+            <label for="input-name">Rule Name</label>
+          </td>
+          <td>
+            <input id="input-name"></input>
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <label for="input-task-specs">Task Spec(s)</label>
+          </td>
+          <td>
+            <multi-input-sk id="input-task-specs"></multi-input-sk>
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <label for="range-checkbox">Commit Range?</label>
+          </td>
+          <td>
+            <input
+              type="checkbox"
+              id="range-checkbox"
+              ?checked="${ele.isCommitRange}"
+              @change="${(ev: Event) => {
+                ele.isCommitRange = (<HTMLInputElement>ev.target).checked;
+                ele._render();
+              }}"
+              >
+            </input>
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <label for="input-range-start">
+              ${
+                ele.isCommitRange ? 'Range start (oldest; inclusive)' : 'Commit'
+              }
+            </label>
+          </td>
+          <td>
+            <input id="input-range-start"></input>
+          </td>
+        </tr>
+        ${
+          ele.isCommitRange
+            ? html`
+        <tr>
+          <td>
+            <label for="input-range-end">Range end (newest; non-inclusive)</label>
+          </td>
+          <td>
+            <input id="input-range-end"></input>
+          </td>
+        </tr>
+        `
+            : html``
+        }
+        <tr>
+          <td>
+            <label for="description">Rule Description</label>
+          </td>
+          <td>
+            <textarea id="input-description" rows="5"></textarea>
+          </td>
+        </tr>
+      </table>
+      <button id="add-button" class="secondary-container-themes-sk" @click="${
+        ele.addRule
+      }">Add Rule</button>
+      <button @click="${() => {
+        $$<HTMLDialogElement>('dialog', ele)?.close();
+      }}">
+        Cancel
+      </button>
+    </dialog>
+  `;
+
+  private isCommitRange: boolean = false;
+  private _rpc: TaskSchedulerService | null = null;
+  private rules: SkipTaskRule[] = [];
+
+  get rpc(): TaskSchedulerService | null {
+    return this._rpc;
+  }
+
+  set rpc(rpc: TaskSchedulerService | null) {
+    this._rpc = rpc;
+    this.reload();
+  }
+
+  constructor() {
+    super(SkipTasksSk.template);
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+  }
+
+  private addRule() {
+    const name = $$<HTMLInputElement>('#input-name', this)!.value;
+    const description = $$<HTMLTextAreaElement>('#input-description', this)!
+      .value;
+    const commitStart = $$<HTMLInputElement>('#input-range-start', this)!.value;
+    const commits = [];
+    if (commitStart) {
+      commits.push(commitStart);
+      if (this.isCommitRange) {
+        const commitEnd = $$<HTMLInputElement>('#input-range-end', this)?.value;
+        if (commitEnd) {
+          commits.push(commitEnd);
+        }
+      }
+    }
+    const taskSpecs = $$<MultiInputSk>('#input-task-specs')!.values;
+    this.rpc!.addSkipTaskRule({
+      name: name,
+      commits: commits,
+      description: description,
+      taskSpecPatterns: taskSpecs,
+    }).then((resp: AddSkipTaskRuleResponse) => {
+      this.rules = resp.rules!;
+      this._render();
+    });
+    $$<HTMLDialogElement>('dialog', this)!.close();
+  }
+
+  private deleteRule(rule: SkipTaskRule) {
+    this.rpc!.deleteSkipTaskRule({ id: rule.name }).then(
+      (resp: DeleteSkipTaskRuleResponse) => {
+        this.rules = resp.rules!;
+        this._render();
+      }
+    );
+  }
+
+  private reload() {
+    this.rpc!.getSkipTaskRules({}).then((resp: GetSkipTaskRulesResponse) => {
+      this.rules = resp.rules!;
+      this._render();
+    });
+  }
+}
+
+define('skip-tasks-sk', SkipTasksSk);
diff --git a/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk_puppeteer_test.ts b/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk_puppeteer_test.ts
new file mode 100644
index 0000000..04a3eb4
--- /dev/null
+++ b/task_scheduler/modules/skip-tasks-sk/skip-tasks-sk_puppeteer_test.ts
@@ -0,0 +1,55 @@
+import * as path from 'path';
+import { expect } from 'chai';
+import {
+  setUpPuppeteerAndDemoPageServer,
+  takeScreenshot,
+} from '../../../puppeteer-tests/util';
+
+describe('skip-tasks-sk', () => {
+  const testBed = setUpPuppeteerAndDemoPageServer(
+    path.join(__dirname, '..', '..', 'webpack.config.ts')
+  );
+
+  beforeEach(async () => {
+    await testBed.page.goto(`${testBed.baseUrl}/dist/skip-tasks-sk.html`);
+    await testBed.page.setViewport({ width: 550, height: 550 });
+  });
+
+  it('should render the demo page (smoke test)', async () => {
+    expect(await testBed.page.$$('skip-tasks-sk')).to.have.length(1);
+  });
+
+  describe('screenshots', () => {
+    it('starting point', async () => {
+      await takeScreenshot(
+        testBed.page,
+        'task-scheduler',
+        'skip-tasks-sk_start'
+      );
+    });
+    it('adds a rule', async () => {
+      await testBed.page.click('add-icon-sk');
+      await testBed.page.type('#input-name', 'New Rule');
+      await testBed.page.type('#input-task-specs input', '.*');
+      // TODO(borenet): I would like to use a commit range here, but I was
+      // unable to automate the checking of the checkbox and subsequent
+      // rendering of the new input field.
+      await testBed.page.type('#input-range-start', 'abc123');
+      await testBed.page.type(
+        '#input-description',
+        'This is a detailed description of the rule.'
+      );
+      await takeScreenshot(
+        testBed.page,
+        'task-scheduler',
+        'skip-tasks-sk_adding-rule'
+      );
+      await testBed.page.click('#add-button');
+      await takeScreenshot(
+        testBed.page,
+        'task-scheduler',
+        'skip-tasks-sk_added-rule'
+      );
+    });
+  });
+});