Merge "Add handling for run actions in new Checks UI"
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 859d82d..87d7a2a 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -224,8 +224,8 @@
 export type ActionCallback = (
   change: number,
   patchset: number,
-  attempt: number,
-  externalId: string,
+  attempt: number | undefined,
+  externalId: string | undefined,
   /** Identical to 'checkName' property of CheckRun. */
   checkName: string,
   /** Identical to 'name' property of Action entity. */
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 6638c5f..09a2319 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -18,11 +18,12 @@
 import {classMap} from 'lit-html/directives/class-map';
 import {css, customElement, property} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
-import {CheckRun, RunStatus} from '../../api/checks';
+import {Action, CheckRun, RunStatus} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
   compareByWorstCategory,
   iconForRun,
+  primaryRunAction,
 } from '../../services/checks/checks-util';
 import {
   allRuns$,
@@ -59,6 +60,33 @@
   );
 }
 
+export interface ActionTriggeredEventDetail {
+  action: Action;
+  run: CheckRun;
+}
+
+export type ActionTriggeredEvent = CustomEvent<ActionTriggeredEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'action-triggered': ActionTriggeredEvent;
+  }
+}
+
+function fireActionTriggered(
+  target: EventTarget,
+  action: Action,
+  run: CheckRun
+) {
+  target.dispatchEvent(
+    new CustomEvent('action-triggered', {
+      detail: {action, run},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
 @customElement('gr-checks-run')
 export class GrChecksRun extends GrLitElement {
   static get styles() {
@@ -70,14 +98,17 @@
           --thick-border: 6px;
         }
         .chip {
-          display: block;
-          font-weight: var(--font-weight-bold);
+          display: flex;
+          justify-content: space-between;
           border: 1px solid var(--border-color);
           border-radius: var(--border-radius);
           padding: var(--spacing-s) var(--spacing-m);
           margin-top: var(--spacing-s);
           cursor: default;
         }
+        .name {
+          font-weight: var(--font-weight-bold);
+        }
         .chip.error {
           border-left: var(--thick-border) solid var(--error-foreground);
         }
@@ -117,6 +148,14 @@
         div.chip.selected iron-icon {
           color: var(--selected-foreground);
         }
+        gr-button.action {
+          --padding: var(--spacing-xs) var(--spacing-m);
+          /* The button should fit into the 20px line-height. The negative
+             margin provides the extra space needed for the vertical padding.
+             Alternatively we could have set the vertical padding to 0, but
+             that would not have been a nice click target. */
+          margin: calc(0px - var(--spacing-xs));
+        }
       `,
     ];
   }
@@ -130,20 +169,39 @@
   render() {
     const icon = this.selected ? 'check-circle' : iconForRun(this.run);
     const classes = {chip: true, [icon]: true, selected: this.selected};
+    const action = primaryRunAction(this.run);
 
     return html`
-      <div @click="${this._handleChipClick}" class="${classMap(classes)}">
-        <iron-icon icon="gr-icons:${icon}"></iron-icon>
-        <span>${this.run.checkName}</span>
+      <div @click="${this.handleChipClick}" class="${classMap(classes)}">
+        <div class="left">
+          <iron-icon icon="gr-icons:${icon}"></iron-icon>
+          <span class="name">${this.run.checkName}</span>
+        </div>
+        <div class="right">
+          ${action
+            ? html`<gr-button
+                class="action"
+                link
+                @click="${(e: MouseEvent) => this.handleAction(e, action)}"
+                >${action.name}</gr-button
+              >`
+            : ''}
+        </div>
       </div>
     `;
   }
 
-  _handleChipClick(e: MouseEvent) {
+  private handleChipClick(e: MouseEvent) {
     e.stopPropagation();
     e.preventDefault();
     fireRunSelected(this, this.run.checkName);
   }
+
+  private handleAction(e: MouseEvent, action: Action) {
+    e.stopPropagation();
+    e.preventDefault();
+    fireActionTriggered(this, action, this.run);
+  }
 }
 
 @customElement('gr-checks-runs')
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 4844593..d31ec53 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -17,13 +17,14 @@
 import {html} from 'lit-html';
 import {css, customElement, property} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
-import {CheckResult, CheckRun} from '../../api/checks';
+import {Action, CheckResult, CheckRun} from '../../api/checks';
 import {allResults$, allRuns$} from '../../services/checks/checks-model';
 import './gr-checks-runs';
 import './gr-checks-results';
 import {sharedStyles} from '../../styles/shared-styles';
-import {currentPatchNum$} from '../../services/change/change-model';
-import {PatchSetNum} from '../../types/common';
+import {changeNum$, currentPatchNum$} from '../../services/change/change-model';
+import {NumericChangeId, PatchSetNum} from '../../types/common';
+import {ActionTriggeredEvent} from './gr-checks-runs';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -39,11 +40,19 @@
   @property()
   currentPatchNum: PatchSetNum | undefined = undefined;
 
+  @property()
+  changeNum: NumericChangeId | undefined = undefined;
+
   constructor() {
     super();
     this.subscribe('runs', allRuns$);
     this.subscribe('results', allResults$);
     this.subscribe('currentPatchNum', currentPatchNum$);
+    this.subscribe('changeNum', changeNum$);
+
+    this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
+      this.handleActionTriggered(e.detail.action, e.detail.run)
+    );
   }
 
   static get styles() {
@@ -70,7 +79,7 @@
           display: flex;
         }
         .runs {
-          min-width: 250px;
+          min-width: 300px;
           min-height: 400px;
           border-right: 1px solid var(--border-color);
         }
@@ -105,6 +114,22 @@
       </div>
     `;
   }
+
+  private handleActionTriggered(action: Action, run: CheckRun) {
+    if (!this.changeNum) return;
+    if (!this.currentPatchNum) return;
+    // TODO(brohlfs): The callback is supposed to be returning a promise.
+    // A toast should be displayed until the promise completes. And then the
+    // data should be updated.
+    action.callback(
+      this.changeNum,
+      this.currentPatchNum as number,
+      run.attempt,
+      run.externalId,
+      run.checkName,
+      action.name
+    );
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index 86b9b47..30b82b6 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Category, CheckRun, RunStatus} from '../../api/checks';
+import {Action, Category, CheckRun, RunStatus} from '../../api/checks';
 import {assertNever} from '../../utils/common-util';
 
 export function worstCategory(run: CheckRun) {
@@ -37,6 +37,42 @@
   }
 }
 
+enum PRIMARY_STATUS_ACTIONS {
+  RERUN = 'rerun',
+  RUN = 'run',
+  CANCEL = 'cancel',
+}
+
+export function toCanonicalAction(action: Action, status: RunStatus) {
+  let name = action.name.toLowerCase();
+  if (status === RunStatus.COMPLETED && (name === 'run' || name === 're-run')) {
+    name = PRIMARY_STATUS_ACTIONS.RERUN;
+  }
+  if (status === RunStatus.RUNNING && name === 'stop') {
+    name = PRIMARY_STATUS_ACTIONS.CANCEL;
+  }
+  return {...action, name};
+}
+
+export function primaryActionName(status: RunStatus) {
+  switch (status) {
+    case RunStatus.COMPLETED:
+      return PRIMARY_STATUS_ACTIONS.RERUN;
+    case RunStatus.RUNNABLE:
+      return PRIMARY_STATUS_ACTIONS.RUN;
+    case RunStatus.RUNNING:
+      return PRIMARY_STATUS_ACTIONS.CANCEL;
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+export function primaryRunAction(run: CheckRun): Action | undefined {
+  return (run.actions ?? [])
+    .map(action => toCanonicalAction(action, run.status))
+    .filter(action => action.name === primaryActionName(run.status))[0];
+}
+
 export function iconForRun(run: CheckRun) {
   const category = worstCategory(run);
   return category ? iconForCategory(category) : iconForStatus(run.status);